Micron Document
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
| SparkN0de-git | SparkN0de |
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------


Commit 2f9a8767c4c9f1c414ce68b4f1e66d5f46c2cd45


Parents : 9c6af87
Author : Ivan <ivan@quad4.io>
Signature : Signature validation error
Date : 2026-05-02T05:22:29-05:00

feat(vendor): add LXMFy as vendored dependancy

Changes

109 files changed, 18257 insertions(+), 0 deletions(-)


Diff

diff --git a/vendor/README.txt b/vendor/README.txt
new file mode 100644
index 00000000..9cd9a3da
--- /dev/null
+++ b/vendor/README.txt
@@ -0,0 +1,8 @@
+Vendored third-party trees shipped inside the reticulum-meshchatx distribution.
+
+lxmfy/
+ Upstream: https://git.quad4.io/LXMFy/LXMFy
+ Bundled revision: a44b08f005bb32bda80bbd9f3c7b5c2baf57f580
+ Declared version (pyproject): see vendor/lxmfy/pyproject.toml
+ Update: clone default branch, replace vendor/lxmfy (omit .git), align vendor/README
+ commit above, run poetry lock / uv lock, regenerate THIRD_PARTY_NOTICES if needed.

diff --git a/vendor/lxmfy/.dockerignore b/vendor/lxmfy/.dockerignore
new file mode 100644
index 00000000..df740cf9
--- /dev/null
+++ b/vendor/lxmfy/.dockerignore
@@ -0,0 +1,10 @@
+CHANGELOG.md
+SECURITY.md
+.git
+.github
+docs
+tests
+.deepsource.toml
+.hypothesis/
+data/
+cogs/
\ No newline at end of file

diff --git a/vendor/lxmfy/.gitea/workflows/bearer.yml b/vendor/lxmfy/.gitea/workflows/bearer.yml
new file mode 100644
index 00000000..63ddca1f
--- /dev/null
+++ b/vendor/lxmfy/.gitea/workflows/bearer.yml
@@ -0,0 +1,30 @@
+name: Bearer
+
+on:
+ push:
+ branches:
+ - master
+ pull_request:
+ branches:
+ - master
+
+permissions:
+ contents: read
+
+jobs:
+ rule_check:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: https://git.quad4.io/actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3
+ - name: Bearer
+ uses: https://git.quad4.io/actions/bearer-action@828eeb928ce2f4a7ca5ed57fb8b59508cb8c79bc # v2
+ with:
+ format: sarif
+ output: results.sarif
+ exit-code: 0
+ - name: Upload SARIF results
+ if: always()
+ uses: https://git.quad4.io/actions/upload-artifact@ff15f0306b3f739f7b6fd43fb5d26cd321bd4de5 # v3
+ with:
+ name: bearer-sarif-results
+ path: results.sarif
\ No newline at end of file

diff --git a/vendor/lxmfy/.gitea/workflows/build-docs.yml b/vendor/lxmfy/.gitea/workflows/build-docs.yml
new file mode 100644
index 00000000..417e1297
--- /dev/null
+++ b/vendor/lxmfy/.gitea/workflows/build-docs.yml
@@ -0,0 +1,200 @@
+name: Build Documentation
+
+on:
+ push:
+ branches: [ master ]
+ workflow_dispatch:
+
+jobs:
+ build:
+ runs-on: ubuntu-latest
+ permissions:
+ contents: write
+ defaults:
+ run:
+ working-directory: ./docs
+
+ steps:
+ - uses: https://git.quad4.io/actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
+
+ - name: Set up Python
+ uses: https://git.quad4.io/actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548
+ with:
+ python-version: '3.13'
+
+ - name: Install Poetry
+ run: |
+ curl -sSL https://install.python-poetry.org | python3 -
+ echo "$HOME/.local/bin" >> $GITHUB_PATH
+
+ - name: Configure Poetry
+ run: |
+ poetry config virtualenvs.create true
+ poetry config virtualenvs.in-project true
+
+ - name: Load cached venv
+ id: cached-poetry-dependencies
+ uses: https://git.quad4.io/actions/cache@2f8e54208210a422b2efd51efaa6bd6d7ca8920f
+ with:
+ path: .venv
+ key: venv-${{ runner.os }}-${{ hashFiles('**/poetry.lock') }}
+
+ - name: Install dependencies
+ run: poetry install --with dev --no-interaction --no-root
+
+ - name: Install LaTeX
+ run: |
+ sudo apt-get update
+ sudo apt-get install -y texlive-latex-recommended texlive-fonts-recommended texlive-extra-utils texlive-latex-extra texlive-latex-base
+ sudo apt-get install -y latexmk
+
+ - name: Get supported languages
+ id: languages
+ run: |
+ # Default language is English (no suffix)
+ languages="en"
+ # Add other languages found in locales directory
+ if [ -d "./locales" ]; then
+ for lang_dir in ./locales/*/; do
+ if [ -d "$lang_dir" ]; then
+ lang=$(basename "$lang_dir")
+ languages="$languages $lang"
+ fi
+ done
+ fi
+ echo "languages=$languages" >> $GITHUB_OUTPUT
+ echo "Found languages: $languages"
+
+ - name: Build documentation for all languages
+ run: |
+ languages="${{ steps.languages.outputs.languages }}"
+ echo "Building documentation for languages: $languages"
+
+ for lang in $languages; do
+ echo "Building documentation for language: $lang"
+
+ # Build HTML
+ if [ "$lang" = "en" ]; then
+ echo "Building English HTML..."
+ poetry run make html
+ else
+ echo "Building $lang HTML..."
+ poetry run make html-$lang
+ fi
+
+ # Build EPUB
+ if [ "$lang" = "en" ]; then
+ echo "Building English EPUB..."
+ poetry run make epub
+ else
+ echo "Building $lang EPUB..."
+ poetry run make epub-$lang
+ fi
+
+ # Build PDF (with error handling)
+ if [ "$lang" = "en" ]; then
+ echo "Building English PDF..."
+ poetry run make latexpdf || echo "English PDF build failed, continuing without PDF"
+ else
+ echo "Building $lang PDF..."
+ poetry run make latexpdf-$lang || echo "$lang PDF build failed, continuing without PDF"
+ fi
+
+ # Build text
+ if [ "$lang" = "en" ]; then
+ echo "Building English text..."
+ poetry run make text
+ else
+ echo "Building $lang text..."
+ poetry run make text-$lang
+ fi
+ done
+
+ - name: Create release archives
+ run: |
+ mkdir -p releases
+
+ # Get supported languages (same logic as before)
+ languages="en"
+ if [ -d "./locales" ]; then
+ for lang_dir in ./locales/*/; do
+ if [ -d "$lang_dir" ]; then
+ lang=$(basename "$lang_dir")
+ languages="$languages $lang"
+ fi
+ done
+ fi
+
+ echo "Creating archives for languages: $languages"
+
+ for lang in $languages; do
+ echo "Creating archives for language: $lang"
+
+ # Copy HTML builds
+ if [ "$lang" = "en" ]; then
+ cp -r build/html releases/html 2>/dev/null || echo "No English HTML found"
+ else
+ cp -r build/html/$lang releases/html-$lang 2>/dev/null || echo "No $lang HTML found"
+ fi
+
+ # Copy EPUB builds
+ if [ "$lang" = "en" ]; then
+ cp build/epub/*.epub releases/ 2>/dev/null || echo "No English EPUB files found"
+ else
+ cp build/epub/$lang/*.epub releases/ 2>/dev/null || echo "No $lang EPUB files found"
+ fi
+
+ # Copy PDF builds
+ if [ "$lang" = "en" ]; then
+ cp build/latex/*.pdf releases/ 2>/dev/null || echo "No English PDF files found"
+ else
+ cp build/latex/$lang/*.pdf releases/ 2>/dev/null || echo "No $lang PDF files found"
+ fi
+
+ # Copy text builds
+ if [ "$lang" = "en" ]; then
+ cp -r build/text releases/text 2>/dev/null || echo "No English text found"
+ else
+ cp -r build/text/$lang releases/text-$lang 2>/dev/null || echo "No $lang text found"
+ fi
+ done
+
+ cd releases
+
+ # Create archives for each language and format
+ for lang in $languages; do
+ echo "Creating archives for language: $lang"
+
+ # HTML archives
+ if [ -d "html${lang:+-$lang}" ]; then
+ tar -czf lxmfy-docs-html${lang:+-${lang}}-$(date +%Y%m%d).tar.gz html${lang:+-$lang}/
+ fi
+
+ # Text archives
+ if [ -d "text${lang:+-$lang}" ]; then
+ tar -czf lxmfy-docs-text${lang:+-${lang}}-$(date +%Y%m%d).tar.gz text${lang:+-$lang}/
+ fi
+ done
+
+ ls -la
+
+ - name: Set release variables
+ id: release_vars
+ run: |
+ echo "tag_name=docs-$(date +%Y%m%d)" >> $GITHUB_OUTPUT
+ echo "release_name=$(date +%Y-%m-%d)" >> $GITHUB_OUTPUT
+
+ - name: Create Release
+ uses: https://git.quad4.io/actions/action-gh-release@6cbd405e2c4e67a21c47fa9e383d020e4e28b836
+ with:
+ tag_name: ${{ steps.release_vars.outputs.tag_name }}
+ name: ${{ steps.release_vars.outputs.release_name }}
+ files: |
+ docs/releases/*.epub
+ docs/releases/*.pdf
+ docs/releases/*.tar.gz
+ make_latest: false
+ draft: false
+ prerelease: false
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

diff --git a/vendor/lxmfy/.gitea/workflows/build-test.yml b/vendor/lxmfy/.gitea/workflows/build-test.yml
new file mode 100644
index 00000000..6ea23c0e
--- /dev/null
+++ b/vendor/lxmfy/.gitea/workflows/build-test.yml
@@ -0,0 +1,27 @@
+name: Build Test
+
+on:
+ push:
+ branches:
+ - master
+ pull_request:
+ branches:
+ - master
+
+jobs:
+ build:
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ strategy:
+ matrix:
+ python-version: ["3.11", "3.12", "3.13"]
+
+ steps:
+ - uses: https://git.quad4.io/actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3
+ - name: Set up Python ${{ matrix.python-version }}
+ uses: https://git.quad4.io/actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
+ with:
+ python-version: ${{ matrix.python-version }}
+ - name: Build Docker Image
+ run: docker build . --file docker/Dockerfile --build-arg PYTHON_VERSION=${{ matrix.python-version }} --tag lxmfy-test:${{ matrix.python-version }}

diff --git a/vendor/lxmfy/.gitea/workflows/docker.yml b/vendor/lxmfy/.gitea/workflows/docker.yml
new file mode 100644
index 00000000..2c39fcd7
--- /dev/null
+++ b/vendor/lxmfy/.gitea/workflows/docker.yml
@@ -0,0 +1,64 @@
+name: Build and Publish Docker Image
+
+on:
+ workflow_dispatch:
+ push:
+ tags:
+ - 'v*'
+
+env:
+ REGISTRY: git.quad4.io
+ IMAGE_NAME: LXMFy/LXMFy
+
+jobs:
+ build:
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ packages: write
+ outputs:
+ image_digest: ${{ steps.build.outputs.digest }}
+ image_tags: ${{ steps.meta.outputs.tags }}
+
+ steps:
+ - name: Checkout repository
+ uses: https://git.quad4.io/actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
+
+ - name: Set up QEMU
+ uses: https://git.quad4.io/actions/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
+ with:
+ platforms: amd64,arm64
+
+ - name: Set up Docker Buildx
+ uses: https://git.quad4.io/actions/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
+
+ - name: Log in to the Container registry
+ uses: https://git.quad4.io/actions/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
+ with:
+ registry: ${{ env.REGISTRY }}
+ username: ${{ secrets.REGISTRY_USERNAME }}
+ password: ${{ secrets.REGISTRY_PASSWORD }}
+
+ - name: Extract metadata (tags, labels) for Docker
+ id: meta
+ uses: https://git.quad4.io/actions/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
+ with:
+ images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
+ tags: |
+ type=raw,value=latest,enable={{is_default_branch}}
+ type=ref,event=branch,prefix=,suffix=,enable={{is_default_branch}}
+ type=ref,event=pr
+ type=semver,pattern={{version}}
+ type=semver,pattern={{major}}.{{minor}}
+ type=sha,format=short
+
+ - name: Build and push Docker image
+ id: build
+ uses: https://git.quad4.io/actions/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
+ with:
+ context: .
+ file: docker/Dockerfile
+ platforms: linux/amd64,linux/arm64
+ push: ${{ github.event_name != 'pull_request' }}
+ tags: ${{ steps.meta.outputs.tags }}
+ labels: ${{ steps.meta.outputs.labels }}

diff --git a/vendor/lxmfy/.gitea/workflows/publish.yml b/vendor/lxmfy/.gitea/workflows/publish.yml
new file mode 100644
index 00000000..3f288b46
--- /dev/null
+++ b/vendor/lxmfy/.gitea/workflows/publish.yml
@@ -0,0 +1,74 @@
+name: Create Release
+
+# This workflow creates releases:
+# 1. Build packages
+# 2. Create Gitea release with all artifacts atomically
+# This ensures releases cannot be modified once published.
+
+on:
+ push:
+ tags:
+ - 'v*'
+ workflow_dispatch:
+ inputs:
+ version:
+ description: 'Version to release (e.g., 0.6.8)'
+ required: true
+ type: string
+
+permissions:
+ contents: write
+
+jobs:
+ release:
+ name: Build and Release
+ runs-on: ubuntu-latest
+ permissions:
+ contents: write
+
+ steps:
+ - name: Checkout
+ uses: https://git.quad4.io/actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
+ with:
+ persist-credentials: false
+
+ - name: Set up Python
+ uses: https://git.quad4.io/actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v5
+ with:
+ python-version: "3.13"
+
+ - name: Install build and twine
+ run: python3 -m pip install build twine --user
+
+ - name: Build a binary wheel and a source tarball
+ run: python3 -m build
+
+ - name: Generate SHA256 checksums
+ run: |
+ sha256sum dist/*.tar.gz dist/*.whl | sed 's|dist/||g' > dist/SHA256SUMS
+ echo "### SHA256 Checksums" > release_notes.md
+ echo '```' >> release_notes.md
+ cat dist/SHA256SUMS >> release_notes.md
+ echo '```' >> release_notes.md
+
+ - name: Publish to Gitea PyPI registry
+ run: python3 -m twine upload --repository-url ${{ github.server_url }}/api/packages/${{ github.repository_owner }}/pypi -u ${{ secrets.REGISTRY_USERNAME }} -p ${{ secrets.REGISTRY_PASSWORD }} dist/*.tar.gz dist/*.whl
+ continue-on-error: true
+
+ - name: Publish to PyPI
+ env:
+ TWINE_USERNAME: __token__
+ TWINE_PASSWORD: ${{ secrets.PYPI_TOKEN }}
+ run: python3 -m twine upload dist/*.tar.gz dist/*.whl
+ continue-on-error: true
+
+ - name: Create Gitea Release with artifacts
+ uses: https://git.quad4.io/actions/gitea-release-action@4875285c0950474efb7ca2df55233c51333eeb74
+ with:
+ tag_name: ${{ inputs.version || github.ref_name }}
+ name: Release ${{ inputs.version || github.ref_name }}
+ body_path: release_notes.md
+ files: |
+ dist/*.tar.gz
+ dist/*.whl
+ dist/SHA256SUMS

diff --git a/vendor/lxmfy/.gitea/workflows/scan.yml b/vendor/lxmfy/.gitea/workflows/scan.yml
new file mode 100644
index 00000000..5b807e16
--- /dev/null
+++ b/vendor/lxmfy/.gitea/workflows/scan.yml
@@ -0,0 +1,26 @@
+name: Security Scans
+
+on:
+ schedule:
+ - cron: "30 12 * * 1"
+ push:
+ branches: [master, dev]
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+jobs:
+ scan:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: https://git.quad4.io/actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
+
+ - name: Download Trivy
+ run: |
+ curl -L -o /tmp/trivy.deb https://git.quad4.io/Quad4-Software/Trivy-Assets/raw/commit/fdfe96b77d2f7b7f5a90cea00af5024c9f728f17/trivy_0.69.3_Linux-64bit.deb
+ sudo dpkg -i /tmp/trivy.deb || sudo apt-get install -f -y
+
+ - name: Trivy FS scan
+ run: trivy fs --exit-code 1 .

diff --git a/vendor/lxmfy/.gitea/workflows/test.yml b/vendor/lxmfy/.gitea/workflows/test.yml
new file mode 100644
index 00000000..d6f189b4
--- /dev/null
+++ b/vendor/lxmfy/.gitea/workflows/test.yml
@@ -0,0 +1,44 @@
+name: Test
+
+on:
+ push:
+ branches:
+ - master
+ pull_request:
+ branches:
+ - master
+
+permissions:
+ contents: read
+ actions: read
+
+jobs:
+ test:
+ runs-on: ubuntu-latest
+ strategy:
+ matrix:
+ python-version: ["3.11", "3.12", "3.13"]
+
+ steps:
+ - uses: https://git.quad4.io/actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3
+
+ - name: Set up Python ${{ matrix.python-version }}
+ uses: https://git.quad4.io/actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
+ with:
+ python-version: ${{ matrix.python-version }}
+
+ - name: Install Poetry
+ run: |
+ curl -sSL https://install.python-poetry.org | python3 -
+ echo "$HOME/.local/bin" >> $GITHUB_PATH
+
+ - name: Configure Poetry
+ run: |
+ poetry config virtualenvs.create true
+ poetry config virtualenvs.in-project false
+
+ - name: Install dependencies
+ run: poetry install --with dev
+
+ - name: Run tests
+ run: poetry run pytest tests/ -v

diff --git a/vendor/lxmfy/.gitignore b/vendor/lxmfy/.gitignore
new file mode 100644
index 00000000..4a89350a
--- /dev/null
+++ b/vendor/lxmfy/.gitignore
@@ -0,0 +1,43 @@
+# Python
+__pycache__/
+*.py[cod]
+*$py.class
+*.so
+.Python
+build/
+develop-eggs/
+dist/
+downloads/
+eggs/
+.eggs/
+lib/
+lib64/
+parts/
+sdist/
+var/
+wheels/
+*.egg-info/
+.installed.cfg
+*.egg
+
+# Virtual Environment
+venv/
+ENV/
+
+# IDE
+.idea/
+.vscode/
+
+# Bot specific
+config/
+data/
+cogs/
+
+# Secrets
+.env
+.pypirc
+
+.ruff_cache/
+
+testing/
+.hypothesis/
\ No newline at end of file

diff --git a/vendor/lxmfy/.pypirc.example b/vendor/lxmfy/.pypirc.example
new file mode 100644
index 00000000..75730fe8
--- /dev/null
+++ b/vendor/lxmfy/.pypirc.example
@@ -0,0 +1,8 @@
+[distutils]
+index-servers = gitea
+
+[gitea]
+repository = https://git.quad4.io/api/packages/LXMFy/pypi
+username = {username}
+password = {password}
+

diff --git a/vendor/lxmfy/CHANGELOG.md b/vendor/lxmfy/CHANGELOG.md
new file mode 100644
index 00000000..a8f15554
--- /dev/null
+++ b/vendor/lxmfy/CHANGELOG.md
@@ -0,0 +1,572 @@
+# Changelog
+
+## [1.6.2] - 2026-04-15
+
+### Features
+- **Reticulum config directory**: Added `reticulum_config_dir` to `BotConfig` (and `LXMFY_RETICULUM_CONFIG_DIR`). `LXMFBot` passes this path to `RNS.Reticulum` for shared instance and auth state; when unset, behavior matches the previous default of using the bot config directory.
+- **Announce display name**: Before each delivery announce, the bot refreshes the LXMF destination display name from the current `LXMFBot.name` / `BotConfig.name`, an optional `announce_display_name_file` under the config directory, or `bot_display_name.txt` when present. This keeps announce app_data aligned when the title changes on disk without restarting the process.
+- **Public announce API**: Added `LXMFBot.announce_now(force=False)` for library callers; use `force=True` to bypass the on-disk announce interval throttle. The `name` property reads and writes `BotConfig.name` and updates the delivery destination when the router is up.
+
+### Updates
+- **Dependencies**: Updated RNS requirement to 1.1.5 and regenerated poetry.lock
+
+## [1.6.1] - 2026-03-11
+
+### Other Changes
+- **License**: Switched from MIT to BSD-0-Clause.
+
+## [1.6.0] - 2026-02-27
+
+Updated dependencies:
+- RNS to 1.1.3
+- Cryptography to 46.0.5
+
+## [1.5.0] - 2026-01-15
+
+### Features
+- **In-Memory Storage**: Added `MemoryStorage` backend. Bots can now run entirely in RAM (excluding RNS/LXMF internal state) by setting `storage_type="memory"`.
+- **Reliability Suite**: Added a comprehensive suite of mathematical and reliability tests:
+ - **Manifold Testing**: NLP vector space orthogonality verification.
+ - **Chaos Engineering**: Storage fault injection and bit-rot simulation.
+ - **Temporal Drift**: Clock skew resilience testing (±1 year jumps).
+ - **Leak Detection**: Resource tracking for FDs, threads, and memory over long runs.
+- **Message Persistence**: Added `message_persistence_enabled` to `BotConfig`. Outgoing messages in the queue are now persisted to storage and restored on startup (in case of a crash or unexpected restart).
+- **Identity Pinning**: Added `identity_pinning_enabled` to `BotConfig`. An extra paranoid measure that remembers the full public key of a sender to protect against theoretical hash collisions.
+- **Dynamic Cog Management**: Added `remove_cog()` and `reload_extension()` methods to `LXMFBot`, allowing for runtime loading and unloading of extensions.
+- **Cross-Language Script Cogs**: Added support for non-Python cogs. Any executable file in the `cogs/` directory is now automatically registered as a bot command. Includes optional sandboxing via `bubblewrap` or `firejail` and mandatory timeouts/threading for safety.
+- **NLP**: Integrated a very basic, lightweight, local intent classification engine (Tiny-NLP). Bots can now understand "intents" using mathematical vector similarity (TF-IDF/Cosine) instead of just exact string matches, all processed locally and offline without external APIs or dependencies.
+- **RNS Link Support**: Bots can now request and respond to direct RNS Links, enabling stateful, link-oriented communication alongside standard LXMF messages.
+- **Type-hinted Argument Parsing**: Bot commands now automatically parse and convert arguments based on type hints in the callback function signature.
+- **Property-based Testing**: Integrated Hypothesis for extensive property-based testing of middleware, parsing, permissions, signatures, storage, and validation modules.
+
+### Fixes
+- **Identity Persistence in Test Mode**: Improved identity handling to allow persistence and recall of identities even when `test_mode` is enabled.
+
+## [1.4.0] - 2026-01-05
+
+### Features
+- **Inbound stamp enforcement toggle**: Added `require_stamps` to `BotConfig`, wiring enforcement through `LXMRouter` initialization and propagation enablement.
+- **Optional identity fetch for unknown senders**: `SignatureManager` can request unknown identities (`request_unknown_identities`) by issuing `RNS.Transport.request_path` when a message arrives from an unknown source.
+- **Performance and memory stress tests**: Added `tests/test_performance.py` with throughput, signature verification, storage load, middleware stack, and long-run memory stability benchmarks.
+
+### Fixes
+- **Reticulum cleanup**: Ensure `LXMFBot.cleanup()` invokes `router.exit_handler()` and `RNS.Reticulum.exit_handler()` to prevent hanging background threads between tests.
+- **Propagation config robustness**: Adjusted propagation tests to use test-mode/mocked routers and ensured storage limits are set correctly, stabilizing propagation-node coverage.
+- **Signature path requests**: Corrected patch target for path requests on unknown identities in tests, aligning with `lxmfy.signatures` usage.
+
+## [1.3.0] - 2026-01-04
+
+### Features
+- **Added version to lxmfy help output**
+
+### Other Changes
+- **Updated publish workflow to use twine for Gitea PyPI package registry**
+- **Updated install commands in README with Gitea PyPI registry instructions**
+- **Added SHA256 checksums for release assets (SHA256SUMS file and in release notes)**
+- **Updated RNS to 1.1.0**
+
+## [1.2.1] - 2025-11-30
+
+### Fixes
+- **Fix Gitea actions setting on project repo (forced pinned SHA actions broke stuff, oops)**
+
+## [1.2.0] - 2025-11-30
+
+### Features
+- **Created dedicated colors module for CLI**
+
+### Fixes
+- **Fix interactive cli color support for Windows 10/11**
+
+### Other Changes
+- **Updated dependencies in poetry.lock (rns 1.0.4 and ruff 0.14.7)**
+- **Moved from safety to bearer for security scanning (safety was no longer working)**
+- **Updated rest of Gitea actions to use full-length commit SHAs for better supply chain security**
+
+## [1.1.0] - 2025-11-21
+
+### Features
+- **Direct Delivery with retries & Propagation Net Fallback**
+- **Configurable Stamp Cost for bots**
+
+### Codebase Changes
+- **Simplied codebase to just use poetry.**
+- **Numerous codebase cleanup and improvements.**
+
+### Updates
+- **Update LXMF to 0.9.3**
+- **Update RNS to 1.0.3**
+
+## [1.0.3] - 2025-11-03
+### Patch
+- **Updated dependencies**
+ - Updated lxmf to 0.9.1 due to bug.
+
+## [1.0.2] - 2025-11-03
+
+### Patch
+- **Updated dependencies**
+ - Updated lxmf to 0.9.0
+ - Updated rns to 1.0.1
+ - Updated dependencies in poetry.lock
+
+- **Project Structure Cleanup**
+ - Moved docker files to docker directory
+ - Updated Makefile and README with new paths.
+
+## [1.0.1] - 2025-09-28
+
+### Patch
+- **Fixed Signature Canonicalization**
+ - Fixed signature canonicalization to use the correct format
+ - updated signature test to use the correct format
+
+## [1.0.0] - 2025-09-27
+
+### Major Features
+- **Stable Release**: LXMFy reaches version 1.0.0 with full feature stability
+- **Comprehensive Test Suite**: Added extensive test coverage
+- **Code Quality Improvements**: Enhanced type hints, removed unused imports, and improved code consistency
+
+### Testing & CI/CD
+- Added pytest framework with comprehensive test suite
+- Implemented Gitea Actions CI/CD pipeline with automated testing
+- Updated DeepSource configuration to exclude test files from analysis
+- Added pytest-related development dependencies
+
+### Code Quality
+- Refactored type hints across multiple files for better consistency
+- Improved help text formatting in HelpFormatter class
+- Removed unused imports and cleaned up code
+- Updated staticmethod usage for better performance
+
+### Dependencies & Configuration
+- Updated project dependencies and configuration
+
+## [0.8.0] - 2025-09-27
+
+### Major Features
+- **Cryptographic Message Signing & Verification**
+ - Added `signature_verification_enabled` configuration option
+ - Added `require_message_signatures` configuration option
+ - Implemented `SignatureManager` class for cryptographic operations
+ - Added automatic signing of outgoing messages when verification is enabled
+ - Added verification of incoming message signatures
+ - Custom LXMF field `FIELD_SIGNATURE = 0xFA` for storing signatures
+ - CLI commands: `lxmfy signatures test/enable/disable`
+ - Integration with permission system (bypass for privileged users)
+ - Comprehensive validation and best practices checking
+
+## [0.7.8] - 2025-09-13
+- **Update Dependencies in poetry.lock**
+- **Add Makefile**
+
+## [0.7.7] - 2025-07-14
+
+- **Docker Enhancements**
+ - Added Arm64 docker support.
+ - Updated docker build test and parameterized Python version in Dockerfile for easier updates.
+
+- **Dependency Updates**
+ - Updated RNS to `1.0.0` and LXMF to `0.8.0`.
+ - Regenerated poetry.lock.
+
+- **Codebase Cleanup**
+ - General code cleanup and maintenance.
+
+## [0.7.6] - 2025-07-05
+
+- **New Feature: Threaded Commands**
+ - Introduced `threaded=True` option for `@command` decorator.
+ - Allows long-running command callbacks to execute in a separate thread, improving bot responsiveness.
+ - Implemented `ThreadPoolExecutor` in `LXMFBot` for managing threaded tasks.
+ - Updated `Command` class to support `threaded` attribute.
+ - Updated `docs/api.md` and `docs/creating-bots.md` with usage and safety guidelines.
+
+- **Dependency Updates**
+ - dependency updates for general maintenance.
+## [0.7.5] - 2025-06-22
+
+- **Enhanced cog command loading system**
+ - Improved add_cog method with robust error handling and command binding
+ - Added proper filtering to skip private methods and non-command attributes
+ - Enhanced command descriptor detection and binding logic
+ - Better fallback handling for edge cases in command registration
+
+- **New CogTest template**
+ - Added comprehensive cog testing template for regression prevention
+ - Includes test commands with various decorator types (@Command, admin-only)
+ - Features status reporting command to verify cog loading success
+ - Available via CLI: `lxmfy create --template cogtest` and `lxmfy run cogtest`
+ - Can be used as both standalone template and loadable cog module
+
+## [0.7.4] - 2025-06-22
+
+- **Fix cog command loading issue**
+ - Fixed Command.__get__ method to properly pass all parameters when binding instance methods
+ - Resolves "'method' object has no attribute 'callback'" error when loading cog extensions
+ - Commands in cogs now load correctly with all metadata preserved
+
+## [0.7.3] - 2025-05-15
+
+- **Update LXMF to 0.7.1**
+- **Update RNS to 0.9.6**
+
+## [0.7.2] - 2025-05-13
+
+- **Update LXMF to 0.7.0**
+- **Update dependencies**
+- **Python 3.13 now required**
+
+## [0.7.1] - 2025-05-09
+
+- **Fixed workflow**
+
+## [0.7.0] - 2025-05-09
+
+- **Add LXMF fields support**
+- **Update dependencies**
+- **Update docs**
+
+## [0.6.9] - 2025-05-07
+
+- **Add opencontainers metadata**
+- **Remove bot Scan (AST)**
+- **Remove bot verification**
+- **Update dependencies**
+- **Performance fixes (Ruff PERF)**
+- **cog loading validation and error handling**
+
+## [0.6.8] - 2025-04-29
+
+- **Update setup.py package name**
+- **Add Dockerfile.Build**
+- **CLI: Colors and Interactive**
+
+## [0.6.7] - 2025-04-29
+
+- **Fix Workflow**
+
+## [0.6.6] - 2025-04-29
+
+- **Fix Basic Tests**
+- **Docstrings**
+- **Code Cleanup**
+- **Meme Bot Template**
+- **Update dependencies**
+- **Add ARMv7 and ARM64 Builds**
+- **Remove Bandit**
+- **Remove Meme Bot (Meme API no longer working)**
+- **Remove Requests (Meme Bot dependency)**
+
+## [0.6.5] - 2025-04-07
+
+- **Fix Attachment System**
+- **Add more storage error handling**
+
+## [0.6.4] - 2025-04-06
+
+- **Code refactoring for security and performance**
+
+## [0.6.3] - 2025-04-06
+
+- **Fix syntax errors**
+- **Manual publish workflow**
+- **Update Poetry.lock**
+
+## [0.6.0] - 2025-04-06
+
+- **Update lxmf to 0.6.3**
+- **Update rns to 0.9.3**
+- **Add docker-compose.yml file**
+- **Run bot templates directly: lxmfy run echo**
+- **Add basic tests**
+- Fix linting errors
+- **Add LXMF Attachment Support**
+
+## [0.5.1] - 2025-02-14
+
+- **Remove unused variables**
+- **Fix version**
+
+## [0.5.0] - 2025-02-14
+
+- **Update config, cli and core**
+ - Add missing values
+ - Fix announce system
+
+## [0.4.9] - 2025-02-14
+- **Fix Announce System**
+ - Add ability to disable announces on start.
+ - Fix announcing interval
+
+Bot configuration:
+
+```python
+ announce=600, # Set the announce interval in seconds, set to 0 to disable periodic announces
+ announce_enabled=True, # Set to False to disable all announces (both initial and periodic)
+```
+
+- **Fix Duplicate Responses**
+
+- **Update Dependencies**
+ - Update LXMF from `0.6.1` to `0.6.2`
+ - Regenerate poetry.lock
+
+## [0.4.8] - 2025-01-25
+- **Fix Storage System**
+ - Serialization errors
+ - SQLite3 Storage Backend
+
+## [0.4.7] - 2025-01-25
+- **Fix Storage System**
+ - Serialization errors
+
+- **Fix Event System**
+ - Event handling of some attributes
+
+## [0.4.6] - 2025-01-25
+
+### Major Features
+- **Middleware System**
+ - Middleware system for processing messages and events
+ - MiddlewareManager class for managing middleware
+ - MiddlewareType enum for middleware types
+ - MiddlewareContext class for passing data through middleware
+
+- **Task Scheduler**
+ - Task scheduler for scheduling tasks
+ - TaskScheduler class for managing tasks
+ - ScheduledTask class for representing scheduled tasks
+
+- **Update lxmf to 0.6.1**
+
+```python
+from lxmfy import LXMFBot, MiddlewareType, TaskScheduler
+
+bot = LXMFBot(name="MyBot")
+
+# Add middleware
+@bot.middleware.register(MiddlewareType.PRE_COMMAND)
+def log_commands(ctx):
+ print(f"Command received: {ctx.data}")
+ return ctx.data
+
+# Schedule task
+@bot.scheduler.schedule("cleanup", "0 */2 * * *") # Every 2 hours
+def cleanup_task():
+ print("Running cleanup...")
+```
+
+## [0.4.5] - 2025-01-20
+
+### Major Features
+- **Event System**
+ - Event system for handling events and middleware
+ - EventManager class for managing events and handlers
+ - Event class for representing events
+ - EventPriority enum for event priority levels
+ - EventMiddleware for handling event middleware
+
+```python
+@bot.events.on("custom_event")
+async def handle_custom_event(event):
+ print(f"Custom event received: {event.data}")
+
+# Dispatch custom event
+await bot.events.dispatch(Event("custom_event", {"foo": "bar"}))
+```
+
+- **Update rns and lxmf**
+
+## [0.4.4] - 2025-01-17
+
+### Major Features
+- **Bot Analysis**
+ - Validate bot configuration and best practices
+ - Analyze bot file and provide recommendations
+ - Validate bot file syntax and structure
+ - Check for common issues and suggest improvements
+
+- **Update rns to 0.9.0**
+
+ cli command: `lxmfy analyze bot.py`
+
+
+## [0.4.3] - 2025-01-04
+
+### Major Features
+- **First Message Handler**
+- **SQLite3 Storage Backend**
+- **Simpler and Better Bot Templates**
+
+Templates Added: EchoBot, ReminderBot, NoteBot
+Templates Removed: FullBot
+
+On First Message Handler:
+
+```python
+
+@bot.on_first_message()
+def welcome_message(sender, message):
+ # Custom welcome message handler
+ bot.send(sender, "Welcome to the bot! Type /help to see available commands.")
+ return True # Return True to indicate message was handled
+```
+
+SQLite3 Storage Backend:
+
+```python
+bot = LXMFBot(
+ name="mybot",
+ announce=600, # Announce every 600 seconds (10 minutes)
+ admins=[], # Add your LXMF hashes here
+ hot_reloading=True,
+ command_prefix="/",
+ first_message_enabled=True,
+ storage_type="sqlite",
+ storage_path="mybot.db",
+)
+```
+
+## [0.4.2] - 2025-01-01
+
+### Major Features - Non-Breaking to existing bots
+- **Permission System**
+ - Role-based access control with hierarchical permissions
+ - Default and admin role system
+ - Custom role creation and management
+ - Persistent permission storage
+ - Command-specific permission requirements
+ - Permission flags: READ, WRITE, EXECUTE, MANAGE
+ - Built-in permission sets: USE_BOT, SEND_MESSAGES, USE_COMMANDS, etc.
+ - Permission inheritance through roles
+ - Permission priority system
+ - Integration with existing admin system
+ - Permission system can be disabled/enabled
+
+### Code Quality
+- **Enhanced Command System**
+ - Permission-aware command decorator
+ - Improved command metadata
+ - Better permission validation
+ - Integration with help system for permission display
+
+### Core Features
+- **Permission Management**
+ - `PermissionManager` class for centralized permission handling
+ - Role assignment and removal
+ - Permission checking utilities
+ - User permission calculation
+ - Role persistence and storage
+
+## [0.4.1] - 2024-31-12
+
+### Major Features
+- **Help Commands**
+ - Detection of existing commands and creates a help command.
+
+## [0.4.0] - 2024-12-29
+
+### Major Features
+- **CLI Templates Command**
+ - Basic bot template with example cogs
+ - Full-featured bot template with storage and admin commands
+ - Template selection via CLI: `lxmfy create --template full mybot`
+
+- **CLI Verification Command**
+ - Using `lxmfy verify` to verify a .whl file using a sigstore hash.
+
+- **Fix Rate Limiting and Spam Protection**
+ - Dont process recieved messages at all if banned.
+
+
+## [0.3.3] - 2024-12-28
+
+### Major Features
+- **Simplified CLI Interface**
+ - New streamlined command: `lxmfy create mybot ./mybot`
+ - Removed complex flag requirements (`--name`, `--output`)
+ - Intuitive directory structure creation
+
+### Code Quality
+- **Enhanced Code Quality**
+ - Full Pylint compliance
+ - Improved type hints
+ - Better error handling
+ - Consistent code style
+
+### Core Features
+- **Transport Layer**
+ - Automatic path discovery
+ - Link caching and management
+ - Request handling system
+ - Configurable timeouts
+ - Path persistence
+
+- **Storage System**
+ - JSON file-based persistence
+ - In-memory caching
+ - Key-value operations
+ - Prefix scanning
+ - Custom backend support
+
+### Documentation
+- **Comprehensive Documentation**
+ - Quick start guide
+ - Command creation examples
+ - Storage system usage
+ - Transport layer integration
+ - Moderation tools overview
+ - Cog system tutorials
+- **Website Updates**
+ - Mobile-responsive design (some more improvements to come)
+ - Improved code block readability
+ - Better navigation structure
+
+### Bug Fixes
+- Fixed mobile navigation menu positioning
+- Improved code block scrolling on mobile devices
+- Enhanced responsive layout for feature cards
+- Fixed documentation link accessibility
+
+[0.3.3]: https://git.quad4.io/LXMFy/LXMFy/releases/tag/v0.3.3
+[0.4.0]: https://git.quad4.io/LXMFy/LXMFy/releases/tag/v0.4.0
+[0.4.1]: https://git.quad4.io/LXMFy/LXMFy/releases/tag/v0.4.1
+[0.4.2]: https://git.quad4.io/LXMFy/LXMFy/releases/tag/v0.4.2
+[0.4.3]: https://git.quad4.io/LXMFy/LXMFy/releases/tag/v0.4.3
+[0.4.4]: https://git.quad4.io/LXMFy/LXMFy/releases/tag/v0.4.4
+[0.4.5]: https://git.quad4.io/LXMFy/LXMFy/releases/tag/v0.4.5
+[0.4.6]: https://git.quad4.io/LXMFy/LXMFy/releases/tag/v0.4.6
+[0.4.7]: https://git.quad4.io/LXMFy/LXMFy/releases/tag/v0.4.7
+[0.4.8]: https://git.quad4.io/LXMFy/LXMFy/releases/tag/v0.4.8
+[0.4.9]: https://git.quad4.io/LXMFy/LXMFy/releases/tag/v0.4.9
+[0.5.0]: https://git.quad4.io/LXMFy/LXMFy/releases/tag/v0.5.0
+[0.5.1]: https://git.quad4.io/LXMFy/LXMFy/releases/tag/v0.5.1
+[0.6.0]: https://git.quad4.io/LXMFy/LXMFy/releases/tag/v0.6.0
+[0.6.3]: https://git.quad4.io/LXMFy/LXMFy/releases/tag/v0.6.3
+[0.6.4]: https://git.quad4.io/LXMFy/LXMFy/releases/tag/v0.6.4
+[0.6.5]: https://git.quad4.io/LXMFy/LXMFy/releases/tag/v0.6.5
+[0.6.6]: https://git.quad4.io/LXMFy/LXMFy/releases/tag/v0.6.6
+[0.6.7]: https://git.quad4.io/LXMFy/LXMFy/releases/tag/v0.6.7
+[0.6.8]: https://git.quad4.io/LXMFy/LXMFy/releases/tag/v0.6.8
+[0.6.9]: https://git.quad4.io/LXMFy/LXMFy/releases/tag/v0.6.9
+[0.7.0]: https://git.quad4.io/LXMFy/LXMFy/releases/tag/v0.7.0
+[0.7.1]: https://git.quad4.io/LXMFy/LXMFy/releases/tag/v0.7.1
+[0.7.2]: https://git.quad4.io/LXMFy/LXMFy/releases/tag/v0.7.2
+[0.7.3]: https://git.quad4.io/LXMFy/LXMFy/releases/tag/v0.7.3
+[0.7.4]: https://git.quad4.io/LXMFy/LXMFy/releases/tag/v0.7.4
+[0.7.5]: https://git.quad4.io/LXMFy/LXMFy/releases/tag/v0.7.5
+[0.7.6]: https://git.quad4.io/LXMFy/LXMFy/releases/tag/v0.7.6
+[0.7.7]: https://git.quad4.io/LXMFy/LXMFy/releases/tag/v0.7.7
+[0.7.8]: https://git.quad4.io/LXMFy/LXMFy/releases/tag/v0.7.8
+[1.0.0]: https://git.quad4.io/LXMFy/LXMFy/releases/tag/v1.0.0
+[1.0.1]: https://git.quad4.io/LXMFy/LXMFy/releases/tag/v1.0.1
+[1.0.2]: https://git.quad4.io/LXMFy/LXMFy/releases/tag/v1.0.2
+[1.0.3]: https://git.quad4.io/LXMFy/LXMFy/releases/tag/v1.0.3
+[1.1.0]: https://git.quad4.io/LXMFy/LXMFy/releases/tag/v1.1.0
+[1.2.0]: https://git.quad4.io/LXMFy/LXMFy/releases/tag/v1.2.0
+[1.2.1]: https://git.quad4.io/LXMFy/LXMFy/releases/tag/v1.2.1
+[1.3.0]: https://git.quad4.io/LXMFy/LXMFy/releases/tag/v1.3.0
+[1.4.0]: https://git.quad4.io/LXMFy/LXMFy/releases/tag/v1.4.0
+[1.5.0]: https://git.quad4.io/LXMFy/LXMFy/releases/tag/v1.5.0

diff --git a/vendor/lxmfy/CONTRIBUTING.md b/vendor/lxmfy/CONTRIBUTING.md
new file mode 100644
index 00000000..93a4aaaf
--- /dev/null
+++ b/vendor/lxmfy/CONTRIBUTING.md
@@ -0,0 +1,42 @@
+# Contributing to LXMFy
+
+Patches are the preferred way to contribute. Create your changes locally,
+export a `.patch` file, and send it over Reticulum.
+
+## Generating a Patch
+
+1. Clone or fork the repository and make your changes on a branch.
+2. Stage and commit your work:
+ ```bash
+ git add -A
+ git commit -m "Short description of the change"
+ ```
+3. Export the commit(s) as a `.patch` file:
+ ```bash
+ # Single most recent commit
+ git format-patch -1
+
+ # Last N commits
+ git format-patch -N
+
+ # All commits since a branch point
+ git format-patch main..HEAD
+ ```
+ This produces one `.patch` file per commit (e.g. `0001-my-change.patch`).
+
+## Sending the Patch
+
+Send the `.patch` file as an LXMF message over Reticulum to:
+
+```
+7cc8d66b4f6a0e0e49d34af7f6077b5a
+```
+
+You can attach the file using Sideband, Meshchat, MeshchatX, or any LXMF-capable client with attachments support.
+Include a brief description of what the patch does in the message body.
+
+## Patch Guidelines
+
+- Keep patches focused on a single change or fix.
+- Test your changes before exporting.
+

diff --git a/vendor/lxmfy/LICENSE b/vendor/lxmfy/LICENSE
new file mode 100644
index 00000000..bb038ace
--- /dev/null
+++ b/vendor/lxmfy/LICENSE
@@ -0,0 +1,12 @@
+Copyright (c) 2024-2026 Quad4
+
+Permission to use, copy, modify, and/or distribute this software for
+any purpose with or without fee is hereby granted.
+
+THE SOFTWARE IS PROVIDED “AS IS” AND THE AUTHOR DISCLAIMS ALL
+WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES
+OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE
+FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY
+DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN
+AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT
+OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
\ No newline at end of file

diff --git a/vendor/lxmfy/Makefile b/vendor/lxmfy/Makefile
new file mode 100644
index 00000000..af36b542
--- /dev/null
+++ b/vendor/lxmfy/Makefile
@@ -0,0 +1,143 @@
+# LXMFy Makefile
+# Override SUDO for install target: make install SUDO=doas or leave unset to auto-detect
+
+PYTHON_VERSION ?= 3.13
+PACKAGE_NAME := lxmfy
+DOCKER_IMAGE ?= lxmfy-test
+WHEEL_BUILDER_IMAGE ?= lxmfy-wheel-builder
+
+SUDO := $(shell command -v doas >/dev/null 2>&1 && echo doas || echo sudo)
+
+.PHONY: default update install install-dev build clean test lint format check dev run
+.PHONY: version bump-patch bump-minor bump-major update-version
+.PHONY: docker docker-build docker-run docker-run-host docker-wheel-build docker-wheel-extract
+.PHONY: docker-compose-build docker-compose-up docker-compose-down docker-compose-logs
+.PHONY: docker-stop docker-clean publish-gitea publish-pypi publish all ci
+
+default:
+ @echo "Targets: update install install-dev build clean test lint format check dev run"
+ @echo " version bump-patch bump-minor bump-major docker docker-build docker-run"
+ @echo " docker-run-host docker-wheel-build docker-wheel-extract docker-stop docker-clean"
+ @echo " docker-compose-build docker-compose-up docker-compose-down docker-compose-logs"
+ @echo " publish-gitea publish-pypi publish all ci"
+
+update:
+ git pull
+
+install:
+ $(SUDO) pip install .
+
+install-dev:
+ poetry install
+ poetry run pip install pytest pytest-asyncio pytest-cov
+
+build:
+ poetry build
+
+clean:
+ rm -rf build/
+ rm -rf dist/
+ rm -rf *.egg-info/
+ rm -rf .pytest_cache/
+ rm -rf __pycache__/
+ find . -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true
+ find . -type f -name "*.pyc" -delete 2>/dev/null || true
+ find . -type f -name "*.pyo" -delete 2>/dev/null || true
+
+test:
+ poetry run pytest tests/ -v
+
+lint:
+ poetry run ruff check .
+
+format:
+ poetry run ruff format .
+
+check:
+ poetry run safety check
+
+dev:
+ poetry install
+
+run:
+ poetry run lxmfy run echo
+
+version:
+ @python -c "import lxmfy; print(lxmfy.__version__)"
+
+bump-patch:
+ poetry version patch
+ $(MAKE) update-version
+
+bump-minor:
+ poetry version minor
+ $(MAKE) update-version
+
+bump-major:
+ poetry version major
+ $(MAKE) update-version
+
+update-version:
+ @NEW_VERSION=$$(poetry version -s); \
+ echo "__version__ = \"$$NEW_VERSION\"" > lxmfy/__version__.py; \
+ echo "Updated version to $$NEW_VERSION"
+
+docker: docker-build docker-run
+
+docker-build:
+ docker build -t $(DOCKER_IMAGE) .
+
+docker-run:
+ docker run -d \
+ --name $(DOCKER_IMAGE)-bot \
+ -v $(CURDIR)/config:/bot/config \
+ -v $(CURDIR)/.reticulum:/root/.reticulum \
+ --restart unless-stopped \
+ $(DOCKER_IMAGE)
+
+docker-run-host:
+ docker run -d \
+ --name $(DOCKER_IMAGE)-bot \
+ --network host \
+ -v $(CURDIR)/config:/bot/config \
+ -v $(CURDIR)/.reticulum:/root/.reticulum \
+ --restart unless-stopped \
+ $(DOCKER_IMAGE)
+
+docker-wheel-build:
+ docker build -f docker/Dockerfile.Build -t $(WHEEL_BUILDER_IMAGE) .
+
+docker-wheel-extract:
+ docker run --rm -v "$(CURDIR)/dist_output:/output" $(WHEEL_BUILDER_IMAGE)
+
+docker-compose-build:
+ docker-compose -f docker/docker-compose.yml build
+
+docker-compose-up:
+ docker-compose -f docker/docker-compose.yml up -d
+
+docker-compose-down:
+ docker-compose -f docker/docker-compose.yml down
+
+docker-compose-logs:
+ docker-compose -f docker/docker-compose.yml logs -f
+
+docker-stop:
+ docker stop $(DOCKER_IMAGE)-bot 2>/dev/null || true
+ docker rm $(DOCKER_IMAGE)-bot 2>/dev/null || true
+
+docker-clean: docker-stop
+ docker rmi $(DOCKER_IMAGE) 2>/dev/null || true
+ docker rmi $(WHEEL_BUILDER_IMAGE) 2>/dev/null || true
+
+publish-gitea: build
+ twine upload --repository-url https://git.quad4.io/api/packages/LXMFy/pypi dist/*
+
+publish-pypi: build
+ twine upload dist/*
+
+publish: publish-gitea publish-pypi
+
+all: clean lint test build
+
+ci: lint check test build

diff --git a/vendor/lxmfy/README.md b/vendor/lxmfy/README.md
new file mode 100644
index 00000000..e6f02077
--- /dev/null
+++ b/vendor/lxmfy/README.md
@@ -0,0 +1,294 @@
+# LXMFy
+
+Easily create LXMF bots for the Reticulum Network with this extensible framework.
+
+[Docs](https://lxmfy.quad4.io)
+
+## Features
+
+| Category | Key Capabilities |
+| :--- | :--- |
+| **Core** | Interactive CLI, Command Prefixes, Cron-style Task Scheduler, Middleware & Event Systems |
+| **Connectivity** | Direct Delivery & Propagation Fallback, Auto-Peering, RNS Link Support, Opportunistic Sending |
+| **Security** | Spam Protection, Role-based Permissions, Identity Pinning, Message Signing/Verification |
+| **NLP** | Local NLP Intent Classification (Offline/Private), Type-hinted Argument Parsing |
+| **Extensions** | Python Cogs, External Script Cogs (Bash, Go, C, etc.), Linux Sandboxing (`bwrap`/`firejail`) |
+| **Storage** | Extensible Backends (JSON, SQLite, In-Memory), Message Persistence (Crash Recovery) |
+| **Reliability** | Extensive Stability & Mathematical Stress Testing, Chaos Engineering, Resource Leak Detection |
+| **UX** | Help on First Message, Auto-generated Help Menus, Customizable Bot Icons, Attachments |
+
+## Installation
+
+There are many ways to install LXMFy, you pick:
+
+### From PyPI
+
+```bash
+# pip
+pip install lxmfy
+
+# pipx
+pipx install lxmfy
+```
+
+### From Gitea Packages
+
+```bash
+# pip
+pip install --index-url https://git.quad4.io/api/packages/LXMFy/pypi/simple/ --extra-index-url https://pypi.org/simple lxmfy
+
+# pipx
+pipx install --pip-args="--index-url https://git.quad4.io/api/packages/LXMFy/pypi/simple/ --extra-index-url https://pypi.org/simple" lxmfy
+```
+
+**Permanent Configuration:**
+
+To avoid typing the index URLs every time, add them to your `pip.conf`:
+
+```ini
+# ~/.config/pip/pip.conf
+[global]
+index-url = https://git.quad4.io/api/packages/LXMFy/pypi/simple/
+extra-index-url = https://pypi.org/simple
+```
+
+Then you can simply use:
+
+```bash
+pip install lxmfy
+# or
+pipx install lxmfy
+```
+
+### Git
+
+```bash
+pip install git+https://git.quad4.io/LXMFy/LXMFy.git
+```
+
+```bash
+pipx install git+https://git.quad4.io/LXMFy/LXMFy.git
+```
+
+### Development Installation
+
+For development, clone the repository and install with poetry:
+
+```bash
+git clone https://git.quad4.io/LXMFy/LXMFy.git
+cd LXMFy
+```
+
+```bash
+poetry install
+```
+
+## Usage
+
+```bash
+lxmfy
+```
+
+**Create bots:**
+
+```bash
+lxmfy create
+```
+
+## Docker
+
+### Building Manually
+
+To build the Docker image, navigate to the root of the project and run:
+
+```bash
+docker build -t lxmfy-test .
+```
+
+Once built, you can run the Docker image:
+
+```bash
+docker run -d \
+ --name lxmfy-test-bot \
+ -v $(pwd)/config:/bot/config \
+ -v $(pwd)/.reticulum:/root/.reticulum \
+ --restart unless-stopped \
+ lxmfy-test
+```
+
+Auto-Interface support (network host):
+
+```bash
+docker run -d \
+ --name lxmfy-test-bot \
+ --network host \
+ -v $(pwd)/config:/bot/config \
+ -v $(pwd)/.reticulum:/root/.reticulum \
+ --restart unless-stopped \
+ lxmfy-test
+```
+
+### Building Wheels with docker/Dockerfile.Build
+
+The `docker/Dockerfile.Build` is used to build the `lxmfy` Python package into a wheel file within a Docker image.
+
+```bash
+docker build -f docker/Dockerfile.Build -t lxmfy-wheel-builder .
+```
+
+This will create an image named `lxmfy-wheel-builder`. To extract the built wheel file from the image, you can run a container from this image and copy the `dist` directory:
+
+```bash
+docker run --rm -v "$(pwd)/dist_output:/output" lxmfy-wheel-builder
+```
+
+This command will create a `dist_output` directory in your current working directory and copy the built wheel file into it.
+
+## Example
+
+```python
+from lxmfy import LXMFBot, load_cogs_from_directory
+
+bot = LXMFBot(
+ name="LXMFy Test Bot", # Name of the bot that appears on the network.
+ announce=5400, # Announce every hour, set to 0 to disable.
+ announce_enabled=True, # Set to False to disable all announces (both initial and periodic)
+ announce_immediately=True, # Set to False to disable initial announce
+ admins=["your_lxmf_hash_here"], # List of admin hashes.
+ hot_reloading=True, # Enable hot reloading.
+ command_prefix="/", # Set to None to process all messages as commands.
+ cogs_dir="cogs", # Specify cogs directory name.
+ rate_limit=5, # 5 messages per minute
+ cooldown=5, # 5 seconds cooldown
+ max_warnings=3, # 3 warnings before ban
+ warning_timeout=300, # Warnings reset after 5 minutes
+ signature_verification_enabled=True, # Enable cryptographic signature verification
+ require_message_signatures=False, # Allow unsigned messages but log them
+ propagation_fallback_enabled=True, # Enable propagation fallback after direct delivery fails
+ propagation_node="your_propagation_node_hash_here", # Manual propagation node (optional)
+ autopeer_propagation=True, # Auto-discover propagation nodes (optional)
+ autopeer_maxdepth=4, # Max hops for auto-peering (default: 4)
+ enable_propagation_node=False, # Run as propagation node (default: False)
+ message_storage_limit_mb=500, # Storage limit in MB for propagation node (default: 500)
+ direct_delivery_retries=3, # Number of direct delivery attempts before falling back to propagation
+)
+
+# Dynamically load all cogs
+load_cogs_from_directory(bot)
+
+@bot.command(name="ping", description="Test if bot is responsive")
+def ping(ctx):
+ ctx.reply("Pong!")
+
+# Admin Only Command
+@bot.command(name="echo", description="Echo a message", admin_only=True)
+def echo(ctx, message: str):
+ ctx.reply(message)
+
+bot.run()
+```
+
+## Propagation Node Configuration
+
+LXMFy supports three modes for propagation node usage:
+
+### 1. Manual Configuration
+
+Set a specific propagation node by hash:
+
+```python
+bot = LXMFBot(
+ name="MyBot",
+ propagation_fallback_enabled=True,
+ propagation_node="your_propagation_node_hash_here", # Manual node configuration
+ direct_delivery_retries=3,
+)
+```
+
+### 2. Automatic Discovery (Auto-Peering)
+
+Let the bot automatically discover and use propagation nodes from network announces:
+
+```python
+bot = LXMFBot(
+ name="MyBot",
+ propagation_fallback_enabled=True,
+ autopeer_propagation=True, # Enable automatic discovery
+ autopeer_maxdepth=4, # Maximum hop distance for auto-peering (default: 4)
+)
+```
+
+The bot will listen for propagation node announces and automatically peer with suitable nodes within the configured hop depth.
+
+### 3. Run as Propagation Node
+
+Your bot can act as a propagation node itself to store and forward messages:
+
+```python
+bot = LXMFBot(
+ name="MyPropagationBot",
+ enable_propagation_node=True, # Enable propagation node mode
+ message_storage_limit_mb=500, # Limit storage to 500 MB (default)
+)
+```
+
+When running as a propagation node, the bot will store messages for offline users and forward them when the recipients come online. The `message_storage_limit_mb` prevents the bot from consuming unlimited disk space. Set to 0 for unlimited storage (not recommended).
+
+### Querying Propagation Status
+
+You can check the current propagation configuration and discovered nodes:
+
+```python
+status = bot.get_propagation_node_status()
+print(f"Current outbound node: {status['current_outbound_node']}")
+print(f"Discovered peers: {status['discovered_peers']}")
+```
+
+### Dynamically Setting Propagation Node
+
+You can change the propagation node at runtime:
+
+```python
+bot.set_propagation_node("new_propagation_node_hash")
+```
+
+### Managing Storage Limits
+
+When running as a propagation node, you can query and adjust storage limits:
+
+```python
+# Get current storage statistics
+stats = bot.get_propagation_storage_stats()
+print(f"Storage used: {stats['storage_size_mb']:.2f} MB")
+print(f"Storage limit: {stats['storage_limit_mb']} MB")
+print(f"Utilization: {stats['utilization_percent']:.1f}%")
+print(f"Messages stored: {stats['message_count']}")
+
+# Change storage limit at runtime
+bot.set_message_storage_limit(megabytes=1000) # Set to 1 GB
+```
+
+### Important Notes
+
+- Without configuring propagation (manual, auto-peer, or running as a node), messages requiring propagation will fail
+- You can combine modes: e.g., set a manual node AND enable auto-peering as backup
+- When running as a propagation node, your bot can still send and receive messages normally
+- Auto-peering respects the `autopeer_maxdepth` setting to avoid connecting to distant nodes
+
+## Development
+
+- poetry
+- python 3.11 or higher
+
+```
+poetry install
+poetry run lxmfy run echo
+```
+
+## Contributing
+
+For now send ideas and issues to LXMF: `7cc8d66b4f6a0e0e49d34af7f6077b5a`
+
+## License
+
+[0BSD](LICENSE)

diff --git a/vendor/lxmfy/SECURITY.md b/vendor/lxmfy/SECURITY.md
new file mode 100644
index 00000000..c4d04755
--- /dev/null
+++ b/vendor/lxmfy/SECURITY.md
@@ -0,0 +1,9 @@
+# Security Policy
+
+This project uses [Bearer](https://www.bearer.com/) and [OSV](https://osv.dev/) for dependency analysis and security scanning on the repository and pull requests.
+
+On Developer side, [Ruff](https://docs.astral.sh/ruff/) is used for security checks, formatting and linting.
+
+## Reporting a Vulnerability
+
+Report using GitHub reporting feature or email [rns@quad4.io](mailto:rns@quad4.io)

diff --git a/vendor/lxmfy/TODO.md b/vendor/lxmfy/TODO.md
new file mode 100644
index 00000000..75681142
--- /dev/null
+++ b/vendor/lxmfy/TODO.md
@@ -0,0 +1,4 @@
+- LXST Support
+- More template bots
+- Improve NLP
+- Knowledge Graph
\ No newline at end of file

diff --git a/vendor/lxmfy/Taskfile.yml b/vendor/lxmfy/Taskfile.yml
new file mode 100644
index 00000000..9f9663e5
--- /dev/null
+++ b/vendor/lxmfy/Taskfile.yml
@@ -0,0 +1,218 @@
+version: '3'
+
+vars:
+ PYTHON_VERSION: '3.13'
+ PACKAGE_NAME: lxmfy
+ DOCKER_IMAGE: lxmfy-test
+ WHEEL_BUILDER_IMAGE: lxmfy-wheel-builder
+
+tasks:
+ default:
+ desc: Show available tasks
+ cmds:
+ - task --list
+
+ update:
+ desc: Pull latest changes from git
+ cmds:
+ - git pull
+
+ install:
+ desc: Install package dependencies
+ cmds:
+ - poetry install
+
+ install-dev:
+ desc: Install development dependencies
+ cmds:
+ - poetry install --with dev
+ - poetry run pip install pytest pytest-asyncio pytest-cov
+
+ build:
+ desc: Build package using poetry
+ cmds:
+ - poetry build
+
+ clean:
+ desc: Clean build artifacts
+ cmds:
+ - rm -rf build/
+ - rm -rf dist/
+ - rm -rf *.egg-info/
+ - rm -rf .pytest_cache/
+ - rm -rf __pycache__/
+ - find . -type d -name __pycache__ -exec rm -rf {} + || true
+ - find . -type f -name "*.pyc" -delete || true
+ - find . -type f -name "*.pyo" -delete || true
+
+ test:
+ desc: Run tests
+ cmds:
+ - poetry run pytest tests/ -v
+
+ lint:
+ desc: Run linting (ruff)
+ cmds:
+ - poetry run ruff check .
+
+ format:
+ desc: Format code (ruff)
+ cmds:
+ - poetry run ruff format .
+
+ check:
+ desc: Run safety check
+ cmds:
+ - poetry run safety check
+
+ dev:
+ desc: Install in development mode
+ cmds:
+ - poetry install
+
+ run:
+ desc: Run echo bot for testing
+ cmds:
+ - poetry run lxmfy run echo
+
+ version:
+ desc: Show current version
+ cmds:
+ - python -c "import lxmfy; print(lxmfy.__version__)"
+
+ bump-patch:
+ desc: Bump patch version
+ cmds:
+ - poetry version patch
+ - task update-version
+
+ bump-minor:
+ desc: Bump minor version
+ cmds:
+ - poetry version minor
+ - task update-version
+
+ bump-major:
+ desc: Bump major version
+ cmds:
+ - poetry version major
+ - task update-version
+
+ update-version:
+ internal: true
+ cmds:
+ - |
+ NEW_VERSION=$(poetry version -s)
+ echo "__version__ = \"$$NEW_VERSION\"" > lxmfy/__version__.py
+ echo "Updated version to $$NEW_VERSION"
+
+ docker:
+ desc: Build and run Docker container
+ deps:
+ - docker-build
+ - docker-run
+
+ docker-build:
+ desc: Build Docker image
+ cmds:
+ - docker build -t {{.DOCKER_IMAGE}} .
+
+ docker-run:
+ desc: Run Docker container
+ cmds:
+ - |
+ docker run -d \
+ --name {{.DOCKER_IMAGE}}-bot \
+ -v $(pwd)/config:/bot/config \
+ -v $(pwd)/.reticulum:/root/.reticulum \
+ --restart unless-stopped \
+ {{.DOCKER_IMAGE}}
+
+ docker-run-host:
+ desc: Run Docker container with host network
+ cmds:
+ - |
+ docker run -d \
+ --name {{.DOCKER_IMAGE}}-bot \
+ --network host \
+ -v $(pwd)/config:/bot/config \
+ -v $(pwd)/.reticulum:/root/.reticulum \
+ --restart unless-stopped \
+ {{.DOCKER_IMAGE}}
+
+ docker-wheel-build:
+ desc: Build wheel builder Docker image
+ cmds:
+ - docker build -f docker/Dockerfile.Build -t {{.WHEEL_BUILDER_IMAGE}} .
+
+ docker-wheel-extract:
+ desc: Extract wheels from builder container
+ cmds:
+ - docker run --rm -v "$(pwd)/dist_output:/output" {{.WHEEL_BUILDER_IMAGE}}
+
+ docker-compose-build:
+ desc: Build using docker-compose
+ cmds:
+ - docker-compose -f docker/docker-compose.yml build
+
+ docker-compose-up:
+ desc: Start services with docker-compose
+ cmds:
+ - docker-compose -f docker/docker-compose.yml up -d
+
+ docker-compose-down:
+ desc: Stop services with docker-compose
+ cmds:
+ - docker-compose -f docker/docker-compose.yml down
+
+ docker-compose-logs:
+ desc: Show docker-compose logs
+ cmds:
+ - docker-compose -f docker/docker-compose.yml logs -f
+
+ docker-stop:
+ desc: Stop and remove Docker container
+ cmds:
+ - docker stop {{.DOCKER_IMAGE}}-bot || true
+ - docker rm {{.DOCKER_IMAGE}}-bot || true
+
+ docker-clean:
+ desc: Clean Docker images and containers
+ deps:
+ - docker-stop
+ cmds:
+ - docker rmi {{.DOCKER_IMAGE}} || true
+ - docker rmi {{.WHEEL_BUILDER_IMAGE}} || true
+
+ publish-gitea:
+ desc: Publish package to Gitea registry
+ deps: [build]
+ cmds:
+ - twine upload --repository-url https://git.quad4.io/api/packages/LXMFy/pypi dist/*
+
+ publish-pypi:
+ desc: Publish package to PyPI
+ deps: [build]
+ cmds:
+ - twine upload dist/*
+
+ publish:
+ desc: Publish package to both Gitea and PyPI
+ deps: [publish-gitea, publish-pypi]
+
+ all:
+ desc: Run clean, lint, test, and build
+ deps:
+ - clean
+ - lint
+ - test
+ - build
+
+ ci:
+ desc: Run CI checks (lint, check, test, build)
+ deps:
+ - lint
+ - check
+ - test
+ - build
+

diff --git a/vendor/lxmfy/docker/Dockerfile b/vendor/lxmfy/docker/Dockerfile
new file mode 100644
index 00000000..377be9e7
--- /dev/null
+++ b/vendor/lxmfy/docker/Dockerfile
@@ -0,0 +1,24 @@
+ARG PYTHON_VERSION=3.13
+FROM python:${PYTHON_VERSION}-alpine
+
+LABEL org.opencontainers.image.source="https://git.quad4.io/LXMFy/LXMFy"
+LABEL org.opencontainers.image.description="Easily create LXMF bots for the Reticulum Network with this extensible framework."
+LABEL org.opencontainers.image.licenses="BSD-0-Clause"
+LABEL org.opencontainers.image.authors="LXMFy"
+
+WORKDIR /bot
+
+RUN mkdir -p /root/.reticulum /bot/config
+
+RUN apk add --no-cache curl && \
+ curl -sSL https://install.python-poetry.org | python3 - && \
+ ln -s /root/.local/bin/poetry /usr/local/bin/poetry
+
+COPY pyproject.toml poetry.lock ./
+RUN poetry config virtualenvs.create false && \
+ poetry install --only main --no-root
+
+COPY . /bot
+RUN poetry install --only main
+
+CMD ["lxmfy", "run", "echo"]

diff --git a/vendor/lxmfy/docker/Dockerfile.Build b/vendor/lxmfy/docker/Dockerfile.Build
new file mode 100644
index 00000000..c8edc446
--- /dev/null
+++ b/vendor/lxmfy/docker/Dockerfile.Build
@@ -0,0 +1,22 @@
+FROM python:3.11-alpine
+
+WORKDIR /build
+
+RUN apk add --no-cache \
+ build-base \
+ python3-dev \
+ git \
+ libffi-dev \
+ openssl-dev \
+ curl
+
+RUN curl -sSL https://install.python-poetry.org | python3 - && \
+ ln -s /root/.local/bin/poetry /usr/local/bin/poetry
+
+COPY pyproject.toml poetry.lock README.md LICENSE ./
+COPY lxmfy ./lxmfy
+
+RUN poetry config virtualenvs.create false && \
+ poetry build
+
+CMD ["cp", "-r", "dist/", "/output/"]

diff --git a/vendor/lxmfy/docker/docker-compose.yml b/vendor/lxmfy/docker/docker-compose.yml
new file mode 100644
index 00000000..f788e7c3
--- /dev/null
+++ b/vendor/lxmfy/docker/docker-compose.yml
@@ -0,0 +1,14 @@
+services:
+ build:
+ build:
+ context: ..
+ dockerfile: docker/Dockerfile.Build
+ volumes:
+ - ./dist:/output
+
+ lxmfy-bot:
+ build: .
+ volumes:
+ - ./.reticulum:/root/.reticulum
+ - ./config:/bot/config
+ command: ["lxmfy", "run", "echo"]

diff --git a/vendor/lxmfy/docs/.dockerignore b/vendor/lxmfy/docs/.dockerignore
new file mode 100644
index 00000000..9b5a723f
--- /dev/null
+++ b/vendor/lxmfy/docs/.dockerignore
@@ -0,0 +1,7 @@
+.git
+.gitea
+*.md
+build/doctrees
+build/epub
+build/latex
+build/text

diff --git a/vendor/lxmfy/docs/.gitignore b/vendor/lxmfy/docs/.gitignore
new file mode 100644
index 00000000..c9d54f26
--- /dev/null
+++ b/vendor/lxmfy/docs/.gitignore
@@ -0,0 +1,29 @@
+build/
+__pycache__/
+*.pyc
+*.pyo
+*.pyd
+.Python
+env/
+venv/
+.venv/
+pip-log.txt
+pip-delete-this-directory.txt
+.tox/
+.coverage
+.coverage.*
+.cache
+nosetests.xml
+coverage.xml
+*.cover
+*.log
+.git
+.mypy_cache
+.pytest_cache
+.hypothesis
+*.egg-info/
+dist/
+*.tar.gz
+*.whl
+.DS_Store
+Thumbs.db

diff --git a/vendor/lxmfy/docs/Dockerfile b/vendor/lxmfy/docs/Dockerfile
new file mode 100644
index 00000000..d847dc2f
--- /dev/null
+++ b/vendor/lxmfy/docs/Dockerfile
@@ -0,0 +1,30 @@
+FROM python:3.13-slim AS builder
+
+WORKDIR /app
+
+COPY pyproject.toml poetry.lock ./
+
+RUN apt-get update && apt-get install -y make && \
+ pip install poetry && \
+ poetry config virtualenvs.create false && \
+ poetry install --with dev --no-interaction --no-root && \
+ apt-get clean && rm -rf /var/lib/apt/lists/*
+
+ENV SPHINXBUILD=sphinx-build
+
+COPY source ./source
+COPY Makefile ./
+
+RUN make html
+
+FROM busybox:latest
+
+RUN adduser -D -s /bin/sh webuser || true
+
+USER webuser
+
+COPY --from=builder --chown=webuser:webuser /app/build/html /home/webuser/html
+
+EXPOSE 8080
+
+CMD ["httpd", "-f", "-v", "-p", "8080", "-h", "/home/webuser/html"]

diff --git a/vendor/lxmfy/docs/Dockerfile.prod b/vendor/lxmfy/docs/Dockerfile.prod
new file mode 100644
index 00000000..a9b21f1e
--- /dev/null
+++ b/vendor/lxmfy/docs/Dockerfile.prod
@@ -0,0 +1,42 @@
+FROM python:3.13-alpine AS builder
+
+WORKDIR /app
+
+COPY pyproject.toml poetry.lock ./
+
+RUN apk add --no-cache make gcc musl-dev && \
+ pip install --no-cache-dir poetry && \
+ poetry config virtualenvs.create false && \
+ poetry install --with dev --no-interaction --no-root --no-cache
+
+ENV SPHINXBUILD=sphinx-build
+
+COPY source ./source
+COPY locales ./locales
+COPY Makefile ./
+
+RUN make html html-ru && \
+ apk del gcc musl-dev
+
+FROM nginx:alpine
+
+RUN addgroup -g 1001 -S nginx-user && \
+ adduser -u 1001 -S nginx-user -G nginx-user && \
+ mkdir -p /var/cache/nginx /var/log/nginx /var/run/nginx /usr/share/nginx/html && \
+ mkdir -p /tmp/nginx_client_temp /tmp/nginx_proxy_temp /tmp/nginx_fastcgi_temp /tmp/nginx_uwsgi_temp /tmp/nginx_scgi_temp && \
+ chown -R nginx-user:nginx-user /var/cache/nginx /var/log/nginx /var/run/nginx /usr/share/nginx/html && \
+ chown -R nginx-user:nginx-user /tmp/nginx_client_temp /tmp/nginx_proxy_temp /tmp/nginx_fastcgi_temp /tmp/nginx_uwsgi_temp /tmp/nginx_scgi_temp && \
+ rm -rf /usr/share/nginx/html/*
+
+COPY --from=builder /app/build/html /usr/share/nginx/html
+COPY nginx.conf /etc/nginx/nginx.conf
+
+RUN chown -R nginx-user:nginx-user /usr/share/nginx/html
+
+USER nginx-user
+
+RUN nginx -t
+
+EXPOSE 8080
+
+CMD ["nginx", "-g", "daemon off;"]

diff --git a/vendor/lxmfy/docs/Makefile b/vendor/lxmfy/docs/Makefile
new file mode 100644
index 00000000..3c0d31cf
--- /dev/null
+++ b/vendor/lxmfy/docs/Makefile
@@ -0,0 +1,46 @@
+# Minimal makefile for Sphinx documentation
+#
+
+# You can set these variables from the command line, and also
+# from the environment for the first two.
+SPHINXOPTS ?=
+SPHINXBUILD ?= poetry run sphinx-build
+SOURCEDIR = source
+BUILDDIR = build
+
+# Put it first so that "make" without argument is like "make help".
+help:
+ @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
+
+.PHONY: help Makefile serve gettext pot html-% epub-% latexpdf-% text-%
+
+serve: html
+ @echo "Starting local server at http://localhost:8000"
+ @cd $(BUILDDIR)/html && python -m http.server 8000
+
+# Translation targets
+gettext:
+ @$(SPHINXBUILD) -b gettext $(SOURCEDIR) $(BUILDDIR)/gettext
+
+pot: gettext
+ @echo "Translatable strings extracted to $(BUILDDIR)/gettext"
+
+# Generic language build targets
+# Usage: make html-ru, make epub-fr, etc.
+html-%: gettext
+ @$(SPHINXBUILD) -b html -D language=$* $(SOURCEDIR) $(BUILDDIR)/html/$*
+
+epub-%: gettext
+ @$(SPHINXBUILD) -b epub -D language=$* $(SOURCEDIR) $(BUILDDIR)/epub/$*
+
+latexpdf-%: gettext
+ @$(SPHINXBUILD) -b latex -D language=$* $(SOURCEDIR) $(BUILDDIR)/latex/$*
+ @$(MAKE) -C $(BUILDDIR)/latex/$*
+
+text-%: gettext
+ @$(SPHINXBUILD) -b text -D language=$* $(SOURCEDIR) $(BUILDDIR)/text/$*
+
+# Catch-all target: route all unknown targets to Sphinx using the new
+# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS).
+%: Makefile
+ @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)

diff --git a/vendor/lxmfy/docs/README.md b/vendor/lxmfy/docs/README.md
new file mode 100644
index 00000000..0211c4ca
--- /dev/null
+++ b/vendor/lxmfy/docs/README.md
@@ -0,0 +1,87 @@
+# LXMFy Docs
+
+Docs for the LXMFy bot framework. Built using Sphinx and Furo theme.
+
+## Building
+
+```bash
+poetry install --with dev
+
+# Build English documentation (HTML, EPUB, PDF, Text)
+make html
+make epub
+make latexpdf
+make text
+
+# Build Russian documentation
+make html-ru
+make epub-ru
+make latexpdf-ru
+make text-ru
+
+# Build documentation for any language (replace XX with language code)
+make html-XX epub-XX latexpdf-XX text-XX
+
+# Build all formats for all languages (English + all in locales/)
+# The Gitea Actions CI does this automatically
+```
+
+## Running
+
+```bash
+make serve
+```
+
+## Docker
+
+### Local/Development (BusyBox)
+
+```bash
+docker build -t lxmfy-docs .
+docker run -p 8080:8080 lxmfy-docs
+```
+
+### Production (Nginx)
+
+```bash
+docker build -f Dockerfile.prod -t lxmfy-docs:prod .
+docker run -p 8080:8080 lxmfy-docs:prod
+```
+
+If using Podman, replace `docker` with `podman`.
+
+## Translations
+
+### How to Add or Update Translations
+
+1. **Generate translation templates:**
+ ```bash
+ make pot
+ ```
+
+2. **For a new language (e.g., `fr` for French):**
+ ```bash
+ # Create directory structure
+ mkdir -p locales/fr/LC_MESSAGES
+
+ # Copy and rename template files
+ cp build/gettext/*.pot locales/fr/LC_MESSAGES/
+ rename 's/\.pot$/.po/' locales/fr/LC_MESSAGES/*.pot
+
+ # Translate the msgstr fields in the .po files
+ ```
+
+3. **For existing languages (update translations):**
+ ```bash
+ # Edit locales/*/LC_MESSAGES/*.po files to update translations
+ ```
+
+4. **Build the translated documentation:**
+ ```bash
+ # Build all formats for your language (replace XX with language code)
+ make html-XX epub-XX latexpdf-XX text-XX
+
+ # Gitea Actions automatically builds all languages and formats
+ ```
+
+**Note:** The system automatically detects all languages in `locales/` and builds all formats for them. Adding a new language requires only creating the translation files - no workflow changes needed!

diff --git a/vendor/lxmfy/docs/docker-compose.prod.yml b/vendor/lxmfy/docs/docker-compose.prod.yml
new file mode 100644
index 00000000..13cab8a7
--- /dev/null
+++ b/vendor/lxmfy/docs/docker-compose.prod.yml
@@ -0,0 +1,66 @@
+services:
+ lxmfy-docs:
+ build:
+ context: .
+ dockerfile: Dockerfile.prod
+ image: lxmfy-docs:prod
+ container_name: lxmfy-docs-prod
+ restart: unless-stopped
+# ports:
+# - "8080:8080"
+
+ # Resource limits
+ deploy:
+ resources:
+ limits:
+ cpus: '0.5'
+ memory: 128M
+ reservations:
+ cpus: '0.1'
+ memory: 32M
+
+ # Security hardening
+ security_opt:
+ - no-new-privileges:true
+ cap_drop:
+ - ALL
+ cap_add:
+ - CHOWN
+ - SETGID
+ - SETUID
+ - NET_BIND_SERVICE
+ read_only: true
+
+ # Temporary filesystems for writable directories
+ tmpfs:
+ - /tmp:noexec,nosuid,size=20m
+
+ # Volumes for persistent directories
+ volumes:
+ - nginx-cache:/var/cache/nginx
+ - nginx-logs:/var/log/nginx
+ - nginx-run:/var/run/nginx
+
+ # Health check
+ healthcheck:
+ test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8080/"]
+ interval: 30s
+ timeout: 10s
+ retries: 3
+ start_period: 30s
+
+ # Environment variables
+ environment:
+ - NGINX_WORKER_PROCESSES=auto
+ - NGINX_WORKER_CONNECTIONS=1024
+
+ # Labels
+ labels:
+ - "com.docker.compose.project=lxmfy-docs"
+ - "com.docker.compose.service=docs"
+ - "maintainer=lxmfy-team"
+
+volumes:
+ nginx-cache:
+ nginx-logs:
+ nginx-run:

diff --git a/vendor/lxmfy/docs/locales/ru/LC_MESSAGES/api-reference.mo b/vendor/lxmfy/docs/locales/ru/LC_MESSAGES/api-reference.mo
new file mode 100644
index 00000000..2d748a94
Binary files /dev/null and b/vendor/lxmfy/docs/locales/ru/LC_MESSAGES/api-reference.mo differ

diff --git a/vendor/lxmfy/docs/locales/ru/LC_MESSAGES/api-reference.po b/vendor/lxmfy/docs/locales/ru/LC_MESSAGES/api-reference.po
new file mode 100644
index 00000000..92279681
--- /dev/null
+++ b/vendor/lxmfy/docs/locales/ru/LC_MESSAGES/api-reference.po
@@ -0,0 +1,535 @@
+# SOME DESCRIPTIVE TITLE.
+# Copyright (C) 2025, Ivan
+# This file is distributed under the same license as the LXMFy package.
+# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
+#
+#, fuzzy
+msgid ""
+msgstr ""
+"Project-Id-Version: LXMFy \n"
+"Report-Msgid-Bugs-To: \n"
+"POT-Creation-Date: 2025-11-20 17:21-0600\n"
+"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
+"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
+"Language-Team: LANGUAGE <LL@li.org>\n"
+"Language: \n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+
+#: ../../source/api-reference.rst:2
+msgid "Core Components"
+msgstr "Основные компоненты"
+
+#: ../../source/api-reference.rst:5
+msgid "LXMFBot"
+msgstr "LXMFBot"
+
+#: ../../source/api-reference.rst:7
+msgid ""
+"The main bot class that handles message routing, command processing, and bot "
+"lifecycle management."
+msgstr ""
+"Основной класс бота, который обрабатывает маршрутизацию сообщений, обработку "
+"команд и управление жизненным циклом бота."
+
+#: ../../source/api-reference.rst:39
+msgid "Key Methods"
+msgstr "Ключевые методы"
+
+#: ../../source/api-reference.rst:41
+msgid ":code:`run(delay=10)`: Start the bot's main loop"
+msgstr ":code:`run(delay=10)`: Запустить основной цикл бота"
+
+#: ../../source/api-reference.rst:42
+#, fuzzy
+msgid ""
+":code:`send(destination, message, title=\"Reply\", lxmf_fields=None, "
+"propagation_node=None, max_retries=3)`: Send a message to a destination, "
+"optionally with custom LXMF fields, specific propagation node, and retry "
+"configuration."
+msgstr ""
+":code:`send(destination, message, title=\"Reply\", lxmf_fields=None)`: "
+"Отправить сообщение получателю, опционально с пользовательскими полями LXMF."
+
+#: ../../source/api-reference.rst:43
+msgid ""
+":code:`send_with_attachment(destination, message, attachment, "
+"title=\"Reply\")`: Send a message with an attachment"
+msgstr ""
+":code:`send_with_attachment(destination, message, attachment, "
+"title=\"Reply\")`: Отправить сообщение с вложением"
+
+#: ../../source/api-reference.rst:44
+msgid ""
+":code:`command(name, description=\"No description provided\", "
+"admin_only=False, threaded=False)`: Decorator for registering commands. "
+"Set :code:`threaded=True` to run the command's callback in a separate thread."
+msgstr ""
+":code:`command(name, description=\"No description provided\", "
+"admin_only=False, threaded=False)`: Декоратор для регистрации команд. "
+"Установите :code:`threaded=True` для запуска обратного вызова команды в "
+"отдельном потоке."
+
+#: ../../source/api-reference.rst:45
+msgid ""
+":code:`on_first_message()`: Decorator for handling first messages from users"
+msgstr ""
+":code:`on_first_message()`: Декоратор для обработки первых сообщений от "
+"пользователей"
+
+#: ../../source/api-reference.rst:46
+#, fuzzy
+msgid ""
+":code:`on_message()`: Decorator for handling all messages (called before "
+"command processing)"
+msgstr ""
+":code:`on_first_message()`: Декоратор для обработки первых сообщений от "
+"пользователей"
+
+#: ../../source/api-reference.rst:47
+msgid ":code:`validate()`: Run validation checks on the bot configuration"
+msgstr ":code:`validate()`: Запустить проверочные тесты конфигурации бота"
+
+#: ../../source/api-reference.rst:50
+msgid "Storage"
+msgstr "Хранилище"
+
+#: ../../source/api-reference.rst:52
+msgid "The framework provides two storage backends:"
+msgstr "Фреймворк предоставляет два бэкенда для хранения данных:"
+
+#: ../../source/api-reference.rst:55
+msgid "JSONStorage"
+msgstr "JSONStorage"
+
+#: ../../source/api-reference.rst:64
+msgid "SQLiteStorage"
+msgstr "SQLiteStorage"
+
+#: ../../source/api-reference.rst:73
+msgid "Commands"
+msgstr "Команды"
+
+#: ../../source/api-reference.rst:75
+msgid "Command registration and handling:"
+msgstr "Регистрация и обработка команд:"
+
+#: ../../source/api-reference.rst:84
+msgid "Threaded Commands"
+msgstr "Многопоточные команды"
+
+#: ../../source/api-reference.rst:86
+msgid ""
+"For long-running or blocking operations that do not interact with the "
+"Reticulum Network Stack directly, you can run commands in a separate thread "
+"to keep the bot responsive."
+msgstr ""
+"Для длительных или блокирующих операций, которые не взаимодействуют напрямую "
+"с сетевым стеком Reticulum, вы можете запускать команды в отдельном потоке, "
+"чтобы бот оставался отзывчивым."
+
+#: ../../source/api-reference.rst:98
+msgid ""
+"**Important:** Functions marked as :code:`threaded=True` **must not** "
+"directly interact with the Reticulum Network Stack (RNS) or any components "
+"that rely on :code:`lxmfy.transport.py`, as these are generally not thread-"
+"safe. Use :code:`ctx.reply()` for sending messages back to the user from "
+"within a threaded command."
+msgstr ""
+"**Важно:** Функции, помеченные как :code:`threaded=True`, **не должны** "
+"напрямую взаимодействовать с сетевым стеком Reticulum (RNS) или любыми "
+"компонентами, которые зависят от :code:`lxmfy.transport.py`, так как они, "
+"как правило, не являются потокобезопасными. Используйте :code:`ctx.reply()` "
+"для отправки сообщений пользователю из многопоточной команды."
+
+#: ../../source/api-reference.rst:101
+msgid "Events"
+msgstr "События"
+
+#: ../../source/api-reference.rst:103
+msgid "Event system for handling various bot events:"
+msgstr "Система событий для обработки различных событий бота:"
+
+#: ../../source/api-reference.rst:113
+msgid "Permissions"
+msgstr "Разрешения"
+
+#: ../../source/api-reference.rst:115
+msgid "Permission system for controlling access to bot features:"
+msgstr "Система разрешений для контроля доступа к функциям бота:"
+
+#: ../../source/api-reference.rst:127
+msgid "Middleware"
+msgstr "Промежуточное ПО"
+
+#: ../../source/api-reference.rst:129
+msgid "Middleware system for processing messages and events:"
+msgstr "Система промежуточного ПО для обработки сообщений и событий:"
+
+#: ../../source/api-reference.rst:139
+msgid "Attachments"
+msgstr "Вложения"
+
+#: ../../source/api-reference.rst:141
+msgid "Support for sending files, images, and audio:"
+msgstr "Поддержка отправки файлов, изображений и аудио:"
+
+#: ../../source/api-reference.rst:156
+msgid "Icon Appearance (LXMF Field)"
+msgstr "Внешний вид иконки (поле LXMF)"
+
+#: ../../source/api-reference.rst:158
+msgid ""
+"You can set a custom icon for your bot that compliant LXMF clients can "
+"display. This uses the :code:`LXMF.FIELD_ICON_APPEARANCE`."
+msgstr ""
+"Вы можете установить пользовательскую иконку для своего бота, которую смогут "
+"отображать совместимые клиенты LXMF. Для этого "
+"используется :code:`LXMF.FIELD_ICON_APPEARANCE`."
+
+#: ../../source/api-reference.rst:189
+msgid "Scheduler"
+msgstr "Планировщик"
+
+#: ../../source/api-reference.rst:191
+msgid "Task scheduling system:"
+msgstr "Система планирования задач:"
+
+#: ../../source/api-reference.rst:201
+msgid "Signatures"
+msgstr "Подписи"
+
+#: ../../source/api-reference.rst:203
+msgid ""
+"LXMFy provides configuration options for LXMF's built-in cryptographic "
+"message signing and verification:"
+msgstr "LXMFy предоставляет параметры конфигурации для встроенной в LXMF криптографической подписи и проверки сообщений:"
+
+#: ../../source/api-reference.rst:215
+msgid ""
+"**Important:** LXMF automatically handles all cryptographic signing and "
+"verification using RNS identities. LXMFy's :code:`SignatureManager` is a "
+"configuration layer that:"
+msgstr "**Важно:** LXMF автоматически обрабатывает все криптографические подписи и проверку с использованием идентификаторов RNS. :code:`SignatureManager` в LXMFy - это слой конфигурации, который:"
+
+#: ../../source/api-reference.rst:217
+msgid "Controls whether to enforce signature verification"
+msgstr "Контролирует, следует ли применять проверку подписи"
+
+#: ../../source/api-reference.rst:218
+msgid "Determines policy for unsigned messages (accept or reject)"
+msgstr "Определяет политику для неподписанных сообщений (принять или отклонить)"
+
+#: ../../source/api-reference.rst:219
+msgid ""
+"Integrates with the permission system (e.g., bypass verification for trusted "
+"users)"
+msgstr "Интегрируется с системой разрешений (например, обход проверки для доверенных пользователей)"
+
+#: ../../source/api-reference.rst:221
+msgid ""
+"The actual cryptographic operations are performed by LXMF/RNS, not by LXMFy."
+msgstr "Фактические криптографические операции выполняются LXMF/RNS, а не LXMFy."
+
+#: ../../source/api-reference.rst:224
+#, fuzzy
+msgid "SignatureManager Methods"
+msgstr "Методы SignatureManager"
+
+#: ../../source/api-reference.rst:226
+msgid ""
+"The :code:`SignatureManager` is available as :code:`bot.signature_manager` "
+"when :code:`signature_verification_enabled=True`:"
+msgstr ":code:`SignatureManager` доступен как :code:`bot.signature_manager`, когда :code:`signature_verification_enabled=True`:"
+
+#: ../../source/api-reference.rst:228
+msgid ""
+":code:`should_verify_message(sender)`: Determine if a message from the given "
+"sender should be verified"
+msgstr ""
+":code:`should_verify_message(sender)`: Определить, следует ли проверять "
+"сообщение от данного отправителя"
+
+#: ../../source/api-reference.rst:229
+#, fuzzy
+msgid ""
+":code:`handle_unsigned_message(sender, message_hash)`: Handle messages that "
+"lack valid signatures based on policy"
+msgstr ""
+":code:`handle_unsigned_message(sender, message_hash)`: Обрабатывать сообщения, "
+"у которых отсутствуют действительные подписи, в соответствии с политикой"
+
+#: ../../source/api-reference.rst:232
+msgid "How LXMF Signatures Work"
+msgstr "Как работают подписи LXMF"
+
+#: ../../source/api-reference.rst:234
+msgid ""
+"LXMF automatically signs all outgoing messages using the sender's RNS "
+"identity during the :code:`pack()` operation. When messages are received, "
+"LXMF validates signatures and provides:"
+msgstr "LXMF автоматически подписывает все исходящие сообщения, используя идентификатор RNS отправителя во время операции :code:`pack()`. При получении сообщений LXMF проверяет подписи и предоставляет:"
+
+#: ../../source/api-reference.rst:236
+msgid ""
+":code:`message.signature_validated`: Boolean indicating if the signature is "
+"valid"
+msgstr ":code:`message.signature_validated`: логическое значение, указывающее, действительна ли подпись"
+
+#: ../../source/api-reference.rst:237
+msgid ""
+":code:`message.unverified_reason`: Reason code if validation failed "
+"(e.g., :code:`SIGNATURE_INVALID`, :code:`SOURCE_UNKNOWN`)"
+msgstr ":code:`message.unverified_reason`: код причины, если проверка не удалась (например, :code:`SIGNATURE_INVALID`, :code:`SOURCE_UNKNOWN`)"
+
+#: ../../source/api-reference.rst:239
+msgid ""
+"LXMFy uses these built-in LXMF properties to enforce your bot's signature "
+"policy."
+msgstr "LXMFy использует эти встроенные свойства LXMF для применения политики подписи вашего бота."
+
+#: ../../source/api-reference.rst:242
+msgid "Message Delivery"
+msgstr "Доставка сообщений"
+
+#: ../../source/api-reference.rst:244
+msgid ""
+"LXMFy provides advanced message delivery features including propagation "
+"nodes and automatic retries:"
+msgstr "LXMFy предоставляет расширенные функции доставки сообщений, включая узлы распространения и автоматические повторы:"
+
+#: ../../source/api-reference.rst:247
+msgid "Propagation Nodes"
+msgstr "Узлы распространения"
+
+#: ../../source/api-reference.rst:249
+msgid ""
+"Send messages through specific propagation nodes for improved reliability on "
+"the Reticulum network:"
+msgstr "Отправляйте сообщения через определенные узлы распространения для повышения надежности в сети Reticulum:"
+
+#: ../../source/api-reference.rst:264
+msgid "Automatic Retries"
+msgstr "Автоматические повторы"
+
+#: ../../source/api-reference.rst:266
+msgid "Configure automatic retry attempts for failed message deliveries:"
+msgstr "Настройте автоматические повторные попытки для неудачных доставок сообщений:"
+
+#: ../../source/api-reference.rst:280
+msgid ""
+"The retry system tracks delivery attempts per destination and automatically "
+"retries failed deliveries. Successful deliveries reset the retry counter for "
+"that destination."
+msgstr "Система повторных попыток отслеживает попытки доставки для каждого получателя и автоматически повторяет неудачные доставки. Успешные доставки сбрасывают счетчик повторных попыток для этого получателя."
+
+#: ../../source/api-reference.rst:283
+msgid "Message Handlers"
+msgstr "Обработчики сообщений"
+
+#: ../../source/api-reference.rst:285
+msgid ""
+"LXMFy provides decorators for handling different types of incoming messages:"
+msgstr "LXMFy предоставляет декораторы для обработки различных типов входящих сообщений:"
+
+#: ../../source/api-reference.rst:288
+msgid "First Message Handler"
+msgstr "Обработчик первого сообщения"
+
+#: ../../source/api-reference.rst:290
+msgid "Handle the first message from each user:"
+msgstr "Обработка первого сообщения от каждого пользователя:"
+
+#: ../../source/api-reference.rst:301
+msgid "General Message Handler"
+msgstr "Общий обработчик сообщений"
+
+#: ../../source/api-reference.rst:303
+msgid "Handle all incoming messages before command processing:"
+msgstr "Обработка всех входящих сообщений перед обработкой команд:"
+
+#: ../../source/api-reference.rst:318
+msgid ""
+"Message handlers are called in this order: 1. First message handler (if this "
+"is the first message from this sender) 2. General message handlers "
+"(registered with :code:`on_message()`) 3. Command processing (if message "
+"starts with command prefix)"
+msgstr "Обработчики сообщений вызываются в следующем порядке: 1. Обработчик первого сообщения (если это первое сообщение от этого отправителя) 2. Общие обработчики сообщений (зарегистрированные с помощью :code:`on_message()`) 3. Обработка команд (если сообщение начинается с префикса команды)"
+
+#: ../../source/api-reference.rst:324
+msgid "Templates"
+msgstr "Шаблоны"
+
+#: ../../source/api-reference.rst:326
+msgid "The framework includes several ready-to-use bot templates:"
+msgstr ""
+"Фреймворк включает в себя несколько готовых к использованию шаблонов ботов:"
+
+#: ../../source/api-reference.rst:329
+msgid "EchoBot"
+msgstr "EchoBot"
+
+#: ../../source/api-reference.rst:331
+msgid "Simple echo bot that repeats messages:"
+msgstr "Простой эхо-бот, который повторяет сообщения:"
+
+#: ../../source/api-reference.rst:341
+msgid "MemeBot"
+msgstr "MemeBot"
+
+#: ../../source/api-reference.rst:343
+msgid "Bot for sending random memes:"
+msgstr "Бот для отправки случайных мемов:"
+
+#: ../../source/api-reference.rst:353
+msgid "NoteBot"
+msgstr "NoteBot"
+
+#: ../../source/api-reference.rst:355
+msgid "Note-taking bot with JSON storage:"
+msgstr "Бот для заметок с хранилищем JSON:"
+
+#: ../../source/api-reference.rst:365
+msgid "ReminderBot"
+msgstr "ReminderBot"
+
+#: ../../source/api-reference.rst:367
+msgid "Reminder bot with SQLite storage:"
+msgstr "Бот для напоминаний с хранилищем SQLite:"
+
+#: ../../source/api-reference.rst:377
+msgid "CLI Tools"
+msgstr "Инструменты командной строки"
+
+#: ../../source/api-reference.rst:379
+msgid "The framework provides command-line tools for bot management:"
+msgstr ""
+"Фреймворк предоставляет инструменты командной строки для управления ботом:"
+
+#: ../../source/api-reference.rst:408
+msgid "Error Handling"
+msgstr "Обработка ошибок"
+
+#: ../../source/api-reference.rst:410
+msgid "The framework provides comprehensive error handling:"
+msgstr "Фреймворк предоставляет комплексную обработку ошибок:"
+
+#~ msgid ""
+#~ "Cryptographic message signing and verification for enhanced security:"
+#~ msgstr ""
+#~ "Криптографическая подпись и проверка сообщений для повышения безопасности:"
+
+#~ msgid ""
+#~ ":code:`sign_message(message, identity)`: Sign an LXMF message using the "
+#~ "provided RNS identity"
+#~ msgstr ""
+#~ ":code:`sign_message(message, identity)`: Подписать сообщение LXMF, "
+#~ "используя предоставленный идентификатор RNS"
+
+#~ msgid ""
+#~ ":code:`verify_message_signature(message, signature, sender_hash, "
+#~ "sender_identity=None)`: Verify a message signature against a sender "
+#~ "identity"
+#~ msgstr ""
+#~ ":code:`verify_message_signature(message, signature, sender_hash, "
+#~ "sender_identity=None)`: Проверить подпись сообщения по идентификатору "
+#~ "отправителя"
+
+#~ msgid ""
+#~ "LXMFy uses custom LXMF field :code:`0xFA` (FIELD_SIGNATURE) to store "
+#~ "cryptographic signatures. Messages are canonicalized by sorting and "
+#~ "concatenating fields in the following order:"
+#~ msgstr ""
+#~ "LXMFy использует пользовательское поле LXMF :code:`0xFA` "
+#~ "(FIELD_SIGNATURE) для хранения криптографических подписей. Сообщения "
+#~ "канонизируются путем сортировки и объединения полей в следующем порядке:"
+
+#~ msgid "source hash (prefixed with \"source:\")"
+#~ msgstr "хэш источника (с префиксом \"source:\")"
+
+#~ msgid "destination hash (prefixed with \"dest:\")"
+#~ msgstr "хэш назначения (с префиксом \"dest:\")"
+
+#~ msgid "content (prefixed with \"content:\")"
+#~ msgstr "содержимое (с префиксом \"content:\")"
+
+#~ msgid "title (prefixed with \"title:\")"
+#~ msgstr "заголовок (с префиксом \"title:\")"
+
+#~ msgid "timestamp (prefixed with \"timestamp:\")"
+#~ msgstr "временная метка (с префиксом \"timestamp:\")"
+
+#~ msgid ""
+#~ "custom fields (excluding signature field, prefixed with \"field_{id}:\")"
+#~ msgstr ""
+#~ "пользовательские поля (исключая поле подписи, с префиксом \"field_{id}:\")"
+
+#~ msgid "Best Practices"
+#~ msgstr "Лучшие практики"
+
+#~ msgid "Always enable the permission system for better security"
+#~ msgstr "Всегда включайте систему разрешений для повышения безопасности"
+
+#~ msgid "Use appropriate storage backend based on data size"
+#~ msgstr ""
+#~ "Используйте подходящий бэкенд для хранения данных в зависимости от их "
+#~ "размера"
+
+#~ msgid "Implement proper error handling in commands"
+#~ msgstr "Реализуйте правильную обработку ошибок в командах"
+
+#~ msgid "Use middleware for cross-cutting concerns"
+#~ msgstr "Используйте промежуточное ПО для сквозных задач"
+
+#~ msgid "Follow the event-driven architecture for extensibility"
+#~ msgstr "Следуйте событийно-ориентированной архитектуре для расширяемости"
+
+#~ msgid "Use the validation system to ensure proper configuration"
+#~ msgstr ""
+#~ "Используйте систему валидации для обеспечения правильной конфигурации"
+
+#~ msgid "Implement proper spam protection"
+#~ msgstr "Реализуйте надлежащую защиту от спама"
+
+#~ msgid "Use the scheduler for periodic tasks"
+#~ msgstr "Используйте планировщик для периодических задач"
+
+#~ msgid "Follow the template structure for new bots"
+#~ msgstr "Следуйте структуре шаблонов для новых ботов"
+
+#~ msgid "Use the CLI tools for bot management"
+#~ msgstr "Используйте инструменты командной строки для управления ботом"
+
+#~ msgid "Security Considerations"
+#~ msgstr "Вопросы безопасности"
+
+#~ msgid "Always validate user input"
+#~ msgstr "Всегда проверяйте вводимые пользователем данные"
+
+#~ msgid "Use the permission system"
+#~ msgstr "Используйте систему разрешений"
+
+#~ msgid "Implement rate limiting"
+#~ msgstr "Реализуйте ограничение частоты запросов"
+
+#~ msgid "Use spam protection"
+#~ msgstr "Используйте защиту от спама"
+
+#~ msgid "Validate attachments"
+#~ msgstr "Проверяйте вложения"
+
+#~ msgid "Use secure storage"
+#~ msgstr "Используйте безопасное хранилище"
+
+#~ msgid "Implement proper error handling"
+#~ msgstr "Реализуйте правильную обработку ошибок"
+
+#~ msgid "Use the validation system"
+#~ msgstr "Используйте систему валидации"
+
+#~ msgid "Follow security best practices"
+#~ msgstr "Следуйте лучшим практикам безопасности"
+
+#~ msgid "Keep dependencies updated"
+#~ msgstr "Своевременно обновляйте зависимости"

diff --git a/vendor/lxmfy/docs/locales/ru/LC_MESSAGES/creating-bots.mo b/vendor/lxmfy/docs/locales/ru/LC_MESSAGES/creating-bots.mo
new file mode 100644
index 00000000..f217a896
Binary files /dev/null and b/vendor/lxmfy/docs/locales/ru/LC_MESSAGES/creating-bots.mo differ

diff --git a/vendor/lxmfy/docs/locales/ru/LC_MESSAGES/creating-bots.po b/vendor/lxmfy/docs/locales/ru/LC_MESSAGES/creating-bots.po
new file mode 100644
index 00000000..e284a655
--- /dev/null
+++ b/vendor/lxmfy/docs/locales/ru/LC_MESSAGES/creating-bots.po
@@ -0,0 +1,537 @@
+# SOME DESCRIPTIVE TITLE.
+# Copyright (C) 2025, Ivan
+# This file is distributed under the same license as the LXMFy package.
+# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
+#
+#, fuzzy
+msgid ""
+msgstr ""
+"Project-Id-Version: LXMFy \n"
+"Report-Msgid-Bugs-To: \n"
+"POT-Creation-Date: 2025-11-20 17:21-0600\n"
+"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
+"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
+"Language-Team: LANGUAGE <LL@li.org>\n"
+"Language: \n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+
+#: ../../source/creating-bots.rst:2
+msgid "Creating Bots"
+msgstr "Создание ботов"
+
+#: ../../source/creating-bots.rst:5
+msgid "Basic Structure"
+msgstr "Базовая структура"
+
+#: ../../source/creating-bots.rst:7
+msgid "A minimal LXMFy bot involves:"
+msgstr "Минимальный бот LXMFy включает в себя:"
+
+#: ../../source/creating-bots.rst:9
+msgid "Importing :code:`LXMFBot`."
+msgstr "Импорт :code:`LXMFBot`."
+
+#: ../../source/creating-bots.rst:10
+msgid "Instantiating :code:`LXMFBot` with desired configuration."
+msgstr "Создание экземпляра :code:`LXMFBot` с желаемой конфигурацией."
+
+#: ../../source/creating-bots.rst:11
+msgid "Defining commands or event handlers."
+msgstr "Определение команд или обработчиков событий."
+
+#: ../../source/creating-bots.rst:12
+msgid "Running the bot using :code:`bot.run()`."
+msgstr "Запуск бота с помощью :code:`bot.run()`."
+
+#: ../../source/creating-bots.rst:60
+msgid "Using Templates"
+msgstr "Использование шаблонов"
+
+#: ../../source/creating-bots.rst:62
+msgid ""
+"LXMFy provides several templates for common bot types. You can use the CLI "
+"to generate a bot file based on a template."
+msgstr ""
+"LXMFy предоставляет несколько шаблонов для распространенных типов ботов. Вы "
+"можете использовать CLI для создания файла бота на основе шаблона."
+
+#: ../../source/creating-bots.rst:78
+msgid ""
+"Running these commands creates a Python file (e.g., :code:`my_echo_bot.py`) "
+"that imports and runs the chosen template. You can then modify the generated "
+"file or the template code itself (:code:`lxmfy/templates/...`)."
+msgstr ""
+"Выполнение этих команд создает файл Python "
+"(например, :code:`my_echo_bot.py`), который импортирует и запускает "
+"выбранный шаблон. Затем вы можете изменить сгенерированный файл или сам код "
+"шаблона (:code:`lxmfy/templates/...`)."
+
+#: ../../source/creating-bots.rst:80
+msgid "**Example generated file (:code:`my_meme_bot.py`):**"
+msgstr "**Пример сгенерированного файла (:code:`my_meme_bot.py`):**"
+
+#: ../../source/creating-bots.rst:93
+msgid "Bot Configuration"
+msgstr "Конфигурация бота"
+
+#: ../../source/creating-bots.rst:95
+msgid ""
+"When creating an :code:`LXMFBot` instance, you can pass various keyword "
+"arguments to configure its behavior. See the :code:`BotConfig` section in "
+"the `API Reference <api-reference.html>`_ or the `Quick Start Guide <quick-"
+"start.html>`_ for a list of common options."
+msgstr ""
+"При создании экземпляра :code:`LXMFBot` вы можете передать различные "
+"именованные аргументы для настройки его поведения. Список распространенных "
+"опций см. в разделе :code:`BotConfig` в `Справочнике API <api-"
+"reference.html>`_ или в `Руководстве по быстрому запуску <quick-"
+"start.html>`_."
+
+#: ../../source/creating-bots.rst:122
+msgid "Setting a Bot Icon (LXMF Field)"
+msgstr "Установка иконки бота (поле LXMF)"
+
+#: ../../source/creating-bots.rst:124
+msgid ""
+"You can give your bot a custom icon that appears in compatible LXMF clients. "
+"This uses the :code:`LXMF.FIELD_ICON_APPEARANCE` and can be set when sending "
+"messages."
+msgstr ""
+"Вы можете присвоить своему боту пользовательскую иконку, которая будет "
+"отображаться в совместимых клиентах LXMF. Для этого "
+"используется :code:`LXMF.FIELD_ICON_APPEARANCE`, и ее можно установить при "
+"отправке сообщений."
+
+#: ../../source/creating-bots.rst:126
+msgid "First, ensure you have the necessary imports:"
+msgstr "Сначала убедитесь, что у вас есть необходимые импорты:"
+
+#: ../../source/creating-bots.rst:132
+msgid "Then, you can define and use the icon:"
+msgstr "Затем вы можете определить и использовать иконку:"
+
+#: ../../source/creating-bots.rst:149
+msgid ""
+"This :code:`self.bot_icon_field` can be pre-calculated and reused for all "
+"messages sent by the bot."
+msgstr ""
+"Это поле :code:`self.bot_icon_field` можно предварительно рассчитать и "
+"использовать повторно для всех сообщений, отправляемых ботом."
+
+#: ../../source/creating-bots.rst:152
+msgid "Using Cogs (Extensions)"
+msgstr "Использование модулей (расширений)"
+
+#: ../../source/creating-bots.rst:154
+msgid ""
+"Cogs allow you to organize your commands and event listeners into separate "
+"files (modules), keeping your main bot file cleaner."
+msgstr ""
+"Модули позволяют вам организовывать команды и прослушиватели событий в "
+"отдельные файлы (модули), сохраняя основной файл вашего бота в чистоте."
+
+#: ../../source/creating-bots.rst:156
+msgid ""
+"**Create a :code:`cogs` directory** (or whatever you set :code:`cogs_dir` to "
+"in :code:`BotConfig`)."
+msgstr ""
+"**Создайте каталог :code:`cogs`** (или любое другое имя, которое вы указали "
+"для :code:`cogs_dir` в :code:`BotConfig`)."
+
+#: ../../source/creating-bots.rst:157
+msgid ""
+"**Create Python files** inside the :code:`cogs` directory "
+"(e.g., :code:`utility.py`)."
+msgstr ""
+"**Создайте файлы Python** внутри каталога :code:`cogs` "
+"(например, :code:`utility.py`)."
+
+#: ../../source/creating-bots.rst:158
+msgid ""
+"**Define a class** that inherits from :code:`lxmfy.Cog` (optional but good "
+"practice) or is just a standard class."
+msgstr ""
+"**Определите класс**, который наследуется от :code:`lxmfy.Cog` "
+"(необязательно, но рекомендуется) или является просто стандартным классом."
+
+#: ../../source/creating-bots.rst:159
+msgid ""
+"**Define commands** as methods within the class using the :code:`@Command` "
+"decorator."
+msgstr ""
+"**Определите команды** как методы внутри класса с помощью "
+"декоратора :code:`@Command`."
+
+#: ../../source/creating-bots.rst:160
+msgid ""
+"**Create a :code:`setup(bot)` function** in the cog file, which LXMFy will "
+"call to register the cog."
+msgstr ""
+"**Создайте функцию :code:`setup(bot)`** в файле модуля, которую LXMFy "
+"вызовет для регистрации модуля."
+
+#: ../../source/creating-bots.rst:162
+msgid "**Example (:code:`cogs/utility.py`):**"
+msgstr "**Пример (:code:`cogs/utility.py`):**"
+
+#: ../../source/creating-bots.rst:200
+msgid "**Main Bot File (:code:`my_bot.py`):**"
+msgstr "**Основной файл бота (:code:`my_bot.py`):**"
+
+#: ../../source/creating-bots.rst:217
+msgid ""
+"When the bot starts, it will automatically find :code:`utility.py`, call "
+"its :code:`setup` function, which creates an instance of :code:`UtilityCog` "
+"and registers it using :code:`bot.add_cog()`. The commands defined in the "
+"cog (:code:`uptime`, :code:`info`) will then be available."
+msgstr ""
+"Когда бот запустится, он автоматически найдет :code:`utility.py`, вызовет "
+"его функцию :code:`setup`, которая создаст экземпляр :code:`UtilityCog` и "
+"зарегистрирует его с помощью :code:`bot.add_cog()`. После этого станут "
+"доступны команды, определенные в модуле (:code:`uptime`, :code:`info`)."
+
+#: ../../source/creating-bots.rst:220
+#, fuzzy
+msgid "Handling Messages"
+msgstr "Обработка событий"
+
+#: ../../source/creating-bots.rst:222
+msgid ""
+"LXMFy provides several ways to handle incoming messages at different stages "
+"of processing."
+msgstr "LXMFy предоставляет несколько способов обработки входящих сообщений на разных этапах."
+
+#: ../../source/creating-bots.rst:225
+msgid "First Message Handler"
+msgstr "Обработчик первого сообщения"
+
+#: ../../source/creating-bots.rst:227
+msgid ""
+"Handle the first message from each new user (useful for welcome messages):"
+msgstr "Обработка первого сообщения от каждого нового пользователя (полезно для приветственных сообщений):"
+
+#: ../../source/creating-bots.rst:252
+msgid "General Message Handler"
+msgstr "Общий обработчик сообщений"
+
+#: ../../source/creating-bots.rst:254
+msgid "Handle all incoming messages before command processing:"
+msgstr "Обработка всех входящих сообщений перед обработкой команд:"
+
+#: ../../source/creating-bots.rst:283
+msgid "Message Handler Processing Order:"
+msgstr "Порядок обработки обработчиков сообщений:"
+
+#: ../../source/creating-bots.rst:285
+msgid ""
+"**First Message Handler** (if :code:`first_message_enabled=True` and this is "
+"first message from sender)"
+msgstr "**Обработчик первого сообщения** (если :code:`first_message_enabled=True` и это первое сообщение от отправителя)"
+
+#: ../../source/creating-bots.rst:286
+msgid ""
+"**General Message Handlers** (registered with :code:`@bot.on_message()`)"
+msgstr "**Общие обработчики сообщений** (зарегистрированные с помощью :code:`@bot.on_message()`)"
+
+#: ../../source/creating-bots.rst:287
+msgid "**Command Processing** (if message matches a registered command)"
+msgstr "**Обработка команд** (если сообщение соответствует зарегистрированной команде)"
+
+#: ../../source/creating-bots.rst:289
+msgid ""
+"Handlers can return :code:`True` to stop further processing or :code:`False` "
+"to continue to the next stage."
+msgstr "Обработчики могут возвращать :code:`True` для прекращения дальнейшей обработки или :code:`False` для перехода к следующему этапу."
+
+#: ../../source/creating-bots.rst:292
+msgid "Handling Events"
+msgstr "Обработка событий"
+
+#: ../../source/creating-bots.rst:294
+msgid ""
+"You can register handlers for various bot events using "
+"the :code:`@bot.events.on()` decorator."
+msgstr ""
+"Вы можете регистрировать обработчики для различных событий бота с помощью "
+"декоратора :code:`@bot.events.on()`."
+
+#: ../../source/creating-bots.rst:337
+msgid ""
+"See :code:`lxmfy/events.py` for more details on the :code:`Event` structure "
+"and priorities."
+msgstr ""
+"См. :code:`lxmfy/events.py` для получения дополнительной информации о "
+"структуре и приоритетах :code:`Event`."
+
+#: ../../source/creating-bots.rst:340
+msgid "Storage"
+msgstr "Хранилище"
+
+#: ../../source/creating-bots.rst:342
+msgid "LXMFy provides JSON and SQLite storage backends."
+msgstr ""
+"LXMFy предоставляет бэкенды для хранения данных в форматах JSON и SQLite."
+
+#: ../../source/creating-bots.rst:344
+msgid ""
+"**JSON:** Simple, human-readable. Good for small datasets. Configure "
+"with :code:`storage_type=\"json\"` "
+"and :code:`storage_path=\"your_data_dir\"`."
+msgstr ""
+"**JSON:** Простой, человекочитаемый формат. Подходит для небольших наборов "
+"данных. Настраивается с помощью :code:`storage_type=\"json\"` "
+"и :code:`storage_path=\"your_data_dir\"`."
+
+#: ../../source/creating-bots.rst:345
+msgid ""
+"**SQLite:** More efficient for larger datasets or frequent writes. Configure "
+"with :code:`storage_type=\"sqlite\"` "
+"and :code:`storage_path=\"your_db_file.db\"`."
+msgstr ""
+"**SQLite:** Более эффективен для больших наборов данных или частых записей. "
+"Настраивается с помощью :code:`storage_type=\"sqlite\"` "
+"и :code:`storage_path=\"your_db_file.db\"`."
+
+#: ../../source/creating-bots.rst:347
+msgid "You can access the storage interface via :code:`bot.storage`:"
+msgstr ""
+"Вы можете получить доступ к интерфейсу хранилища через :code:`bot.storage`:"
+
+#: ../../source/creating-bots.rst:371
+msgid "See :code:`lxmfy/storage.py` and the API reference for more details."
+msgstr ""
+"См. :code:`lxmfy/storage.py` и справочник по API для получения "
+"дополнительной информации."
+
+#: ../../source/creating-bots.rst:374
+msgid "Permissions"
+msgstr "Разрешения"
+
+#: ../../source/creating-bots.rst:376
+msgid ""
+"LXMFy includes an optional role-based permission system. Enable it "
+"with :code:`permissions_enabled=True` during :code:`LXMFBot` initialization."
+msgstr ""
+"LXMFy включает дополнительную систему разрешений на основе ролей. Включите "
+"ее с помощью :code:`permissions_enabled=True` во время "
+"инициализации :code:`LXMFBot`."
+
+#: ../../source/creating-bots.rst:378
+msgid ""
+"**Roles:** Define roles with specific permissions "
+"(e.g., :code:`DefaultPerms.MANAGE_USERS`)."
+msgstr ""
+"**Роли:** Определите роли с определенными разрешениями "
+"(например, :code:`DefaultPerms.MANAGE_USERS`)."
+
+#: ../../source/creating-bots.rst:379
+msgid ""
+"**Permissions:** Granular flags defined in :code:`DefaultPerms` "
+"(e.g., :code:`USE_COMMANDS`, :code:`BYPASS_SPAM`)."
+msgstr ""
+"**Разрешения:** Детальные флаги, определенные в :code:`DefaultPerms` "
+"(например, :code:`USE_COMMANDS`, :code:`BYPASS_SPAM`)."
+
+#: ../../source/creating-bots.rst:380
+msgid "**Assignment:** Assign roles to user hashes."
+msgstr "**Назначение:** Назначьте роли хэшам пользователей."
+
+#: ../../source/creating-bots.rst:382
+msgid ""
+"See :code:`lxmfy/permissions.py`, the API reference, and potentially example "
+"cogs (if any are created) for usage details."
+msgstr ""
+"Подробности использования см. в :code:`lxmfy/permissions.py`, справочнике по "
+"API и, возможно, в примерах модулей (если они есть)."
+
+#: ../../source/creating-bots.rst:385
+msgid "Signature Verification"
+msgstr "Проверка подписи"
+
+#: ../../source/creating-bots.rst:387
+msgid ""
+"LXMFy provides configuration for LXMF's built-in cryptographic message "
+"signing and verification. All LXMF messages are automatically signed by the "
+"LXMF/RNS stack - LXMFy simply allows you to enforce signature verification "
+"policies."
+msgstr ""
+
+#: ../../source/creating-bots.rst:389
+#, fuzzy
+msgid "**Configuration:**"
+msgstr "**Конфигурация:**"
+
+#: ../../source/creating-bots.rst:391
+msgid "Enable signature verification in your bot configuration:"
+msgstr "Включите проверку подписи в конфигурации вашего бота:"
+
+#: ../../source/creating-bots.rst:401
+msgid "**How It Works:**"
+msgstr "**Как это работает:**"
+
+#: ../../source/creating-bots.rst:403
+msgid "LXMF automatically handles all cryptographic operations:"
+msgstr ""
+
+#: ../../source/creating-bots.rst:405
+#, fuzzy
+msgid ""
+"**Outgoing Messages:** LXMF automatically signs all messages using the "
+"sender's RNS identity during message packing."
+msgstr ""
+"**Входящие сообщения:** Бот проверяет подписи на входящих сообщениях, "
+"используя идентификатор RNS отправителя."
+
+#: ../../source/creating-bots.rst:407
+#, fuzzy
+msgid ""
+"**Incoming Messages:** LXMF automatically validates signatures using the "
+"sender's RNS identity and provides validation results."
+msgstr ""
+"**Входящие сообщения:** Бот проверяет подписи на входящих сообщениях, "
+"используя идентификатор RNS отправителя."
+
+#: ../../source/creating-bots.rst:409
+msgid ""
+"**LXMFy's Role:** LXMFy checks LXMF's validation results and enforces your "
+"policy:"
+msgstr "**Роль LXMFy:** LXMFy проверяет результаты проверки LXMF и применяет вашу политику:"
+
+#: ../../source/creating-bots.rst:411
+msgid ""
+"If :code:`signature_verification_enabled=False`: All messages are accepted "
+"(default)"
+msgstr "Если :code:`signature_verification_enabled=False`: все сообщения принимаются (по умолчанию)"
+
+#: ../../source/creating-bots.rst:412
+msgid ""
+"If :code:`signature_verification_enabled=True` "
+"and :code:`require_message_signatures=False`: Messages are accepted but "
+"unsigned/invalid signatures are logged"
+msgstr "Если :code:`signature_verification_enabled=True` и :code:`require_message_signatures=False`: сообщения принимаются, но неподписанные/недействительные подписи регистрируются"
+
+#: ../../source/creating-bots.rst:413
+msgid ""
+"If :code:`signature_verification_enabled=True` "
+"and :code:`require_message_signatures=True`: Unsigned or invalid messages "
+"are rejected"
+msgstr "Если :code:`signature_verification_enabled=True` и :code:`require_message_signatures=True`: неподписанные или недействительные сообщения отклоняются"
+
+#: ../../source/creating-bots.rst:415
+#, fuzzy
+msgid ""
+"**Permission Integration:** Users with :code:`BYPASS_SPAM` permission can "
+"bypass signature verification requirements."
+msgstr "**Интеграция с разрешениями:** Пользователи с разрешением :code:`BYPASS_SPAM` могут обходить требования проверки подписи."
+
+#: ../../source/creating-bots.rst:417
+msgid "**CLI Management:**"
+msgstr "**Управление через CLI:**"
+
+#: ../../source/creating-bots.rst:419
+msgid "You can manage signature verification settings using the CLI:"
+msgstr "Вы можете управлять настройками проверки подписи с помощью CLI:"
+
+#: ../../source/creating-bots.rst:432
+msgid "**Technical Details:**"
+msgstr "**Технические детали:**"
+
+#: ../../source/creating-bots.rst:434
+msgid ""
+"LXMF uses Ed25519 signatures provided by the RNS cryptography system. Every "
+"LXMF message includes the sender's signature, which is validated against "
+"their known RNS identity. LXMFy simply reads "
+"LXMF's :code:`message.signature_validated` property "
+"and :code:`message.unverified_reason` to enforce your bot's security policy."
+msgstr "LXMF использует подписи Ed25519, предоставляемые криптографической системой RNS. Каждое сообщение LXMF включает подпись отправителя, которая проверяется по его известному идентификатору RNS. LXMFy просто считывает свойства LXMF :code:`message.signature_validated` и :code:`message.unverified_reason` для применения политики безопасности вашего бота."
+
+#: ../../source/creating-bots.rst:437
+msgid "Advanced Message Delivery"
+msgstr "Расширенная доставка сообщений"
+
+#: ../../source/creating-bots.rst:439
+msgid ""
+"LXMFy supports advanced message delivery options for improved reliability."
+msgstr "LXMFy поддерживает расширенные параметры доставки сообщений для повышения надежности."
+
+#: ../../source/creating-bots.rst:442
+msgid "Using Propagation Nodes"
+msgstr "Использование узлов распространения"
+
+#: ../../source/creating-bots.rst:444
+msgid "Send messages through specific LXMF propagation nodes:"
+msgstr "Отправляйте сообщения через определенные узлы распространения LXMF:"
+
+#: ../../source/creating-bots.rst:461
+msgid ""
+"Propagation nodes are useful when direct delivery is not possible or when "
+"you want to ensure message delivery through the Reticulum mesh network."
+msgstr "Узлы распространения полезны, когда прямая доставка невозможна или когда вы хотите обеспечить доставку сообщений через ячеистую сеть Reticulum."
+
+#: ../../source/creating-bots.rst:464
+msgid "Configuring Retries"
+msgstr "Настройка повторных попыток"
+
+#: ../../source/creating-bots.rst:466
+msgid "Configure automatic retry attempts for failed message deliveries:"
+msgstr "Настройте автоматические повторные попытки для неудачных доставок сообщений:"
+
+#: ../../source/creating-bots.rst:488
+msgid "The retry system:"
+msgstr "Система повторных попыток:"
+
+#: ../../source/creating-bots.rst:490
+msgid "Automatically tracks delivery attempts per destination"
+msgstr "Автоматически отслеживает попытки доставки для каждого получателя"
+
+#: ../../source/creating-bots.rst:491
+msgid "Retries failed deliveries up to the specified :code:`max_retries`"
+msgstr "Повторяет неудачные доставки до указанного :code:`max_retries`"
+
+#: ../../source/creating-bots.rst:492
+msgid "Resets the retry counter on successful delivery"
+msgstr "Сбрасывает счетчик повторных попыток при успешной доставке"
+
+#: ../../source/creating-bots.rst:493
+msgid "Logs retry attempts and failures for debugging"
+msgstr "Регистрирует попытки повтора и сбои для отладки"
+
+#~ msgid ""
+#~ "LXMFy supports cryptographic message signing and verification for "
+#~ "enhanced security. When enabled, the bot will verify that incoming "
+#~ "messages are signed with valid RNS identities."
+#~ msgstr ""
+#~ "LXMFy поддерживает криптографическую подпись и проверку сообщений для "
+#~ "повышения безопасности. Когда эта функция включена, бот будет проверять, "
+#~ "что входящие сообщения подписаны действительными идентификаторами RNS."
+
+#~ msgid ""
+#~ "**Outgoing Messages:** When signature verification is enabled, all "
+#~ "outgoing messages are automatically signed using the bot's RNS identity."
+#~ msgstr ""
+#~ "**Исходящие сообщения:** Когда проверка подписи включена, все исходящие "
+#~ "сообщения автоматически подписываются с использованием идентификатора RNS "
+#~ "бота."
+
+#~ msgid ""
+#~ "**Unsigned Messages:** If :code:`require_message_signatures=True`, "
+#~ "unsigned messages are rejected. Otherwise, they're accepted but logged."
+#~ msgstr ""
+#~ "**Неподписанные сообщения:** Если "
+#~ "установлено :code:`require_message_signatures=True`, неподписанные "
+#~ "сообщения отклоняются. В противном случае они принимаются, но "
+#~ "регистрируются."
+
+#~ msgid "**Signature Fields:**"
+#~ msgstr "**Поля подписи:**"
+
+#~ msgid ""
+#~ "Signatures are stored in LXMF field :code:`0xFA`. The message "
+#~ "canonicalization process ensures consistent signing by ordering message "
+#~ "components deterministically."
+#~ msgstr ""
+#~ "Подписи хранятся в поле LXMF :code:`0xFA`. Процесс канонизации сообщения "
+#~ "обеспечивает последовательную подпись путем детерминированного "
+#~ "упорядочивания компонентов сообщения."

diff --git a/vendor/lxmfy/docs/locales/ru/LC_MESSAGES/index.mo b/vendor/lxmfy/docs/locales/ru/LC_MESSAGES/index.mo
new file mode 100644
index 00000000..dff07128
Binary files /dev/null and b/vendor/lxmfy/docs/locales/ru/LC_MESSAGES/index.mo differ

diff --git a/vendor/lxmfy/docs/locales/ru/LC_MESSAGES/index.po b/vendor/lxmfy/docs/locales/ru/LC_MESSAGES/index.po
new file mode 100644
index 00000000..d65f2e62
--- /dev/null
+++ b/vendor/lxmfy/docs/locales/ru/LC_MESSAGES/index.po
@@ -0,0 +1,64 @@
+# SOME DESCRIPTIVE TITLE.
+# Copyright (C) 2025, Ivan
+# This file is distributed under the same license as the LXMFy package.
+# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
+#
+#, fuzzy
+msgid ""
+msgstr ""
+"Project-Id-Version: LXMFy \n"
+"Report-Msgid-Bugs-To: \n"
+"POT-Creation-Date: 2025-09-27 16:06-0500\n"
+"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
+"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
+"Language-Team: LANGUAGE <LL@li.org>\n"
+"Language: \n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+
+#: ../../source/index.rst:19
+msgid "Contents:"
+msgstr "Содержание:"
+
+#: ../../source/index.rst:2
+msgid "lxmfy documentation"
+msgstr "Документация lxmfy"
+
+#: ../../source/index.rst:4
+msgid ""
+"A framework for creating `LXMF <https://git.quad4.io/LXMFy/LXMFy>`_ bots on "
+"the `Reticulum Network <https://reticulum.network/>`_."
+msgstr ""
+"Фреймворк для создания ботов `LXMF <https://git.quad4.io/LXMFy/LXMFy>`_ в "
+"сети `Reticulum <https://reticulum.network/>`_."
+
+#: ../../source/index.rst:7
+msgid "Download"
+msgstr "Скачать"
+
+#: ../../source/index.rst:9
+msgid ""
+"Get the latest version of LXMFy docs (PDF, EPUB, HTML and Text) from our "
+"`Gitea repository <https://git.quad4.io/LXMFy/LXMFy>`_."
+msgstr ""
+"Получите последнюю версию документации LXMFy (PDF, EPUB, HTML и Text) из "
+"нашего `репозитория на Gitea <https://git.quad4.io/LXMFy/LXMFy>`_."
+
+#: ../../source/index.rst:12
+msgid "Languages"
+msgstr "Языки"
+
+#: ../../source/index.rst:14
+msgid "This documentation is available in the following languages:"
+msgstr "Эта документация доступна на следующих языках:"
+
+#: ../../source/index.rst:16
+#, fuzzy
+msgid "`English <index.html>`_"
+msgstr "* `English <index.html>`_"
+
+#: ../../source/index.rst:17
+#, fuzzy
+msgid "`Русский <ru/index.html>`_"
+msgstr "* `Русский <ru/index.html>`_"

diff --git a/vendor/lxmfy/docs/locales/ru/LC_MESSAGES/quick-start.mo b/vendor/lxmfy/docs/locales/ru/LC_MESSAGES/quick-start.mo
new file mode 100644
index 00000000..9c85f8b2
Binary files /dev/null and b/vendor/lxmfy/docs/locales/ru/LC_MESSAGES/quick-start.mo differ

diff --git a/vendor/lxmfy/docs/locales/ru/LC_MESSAGES/quick-start.po b/vendor/lxmfy/docs/locales/ru/LC_MESSAGES/quick-start.po
new file mode 100644
index 00000000..29bf185e
--- /dev/null
+++ b/vendor/lxmfy/docs/locales/ru/LC_MESSAGES/quick-start.po
@@ -0,0 +1,220 @@
+# SOME DESCRIPTIVE TITLE.
+# Copyright (C) 2025, Ivan
+# This file is distributed under the same license as the LXMFy package.
+# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
+#
+#, fuzzy
+msgid ""
+msgstr ""
+"Project-Id-Version: LXMFy \n"
+"Report-Msgid-Bugs-To: \n"
+"POT-Creation-Date: 2025-11-20 17:21-0600\n"
+"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
+"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
+"Language-Team: LANGUAGE <LL@li.org>\n"
+"Language: \n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: 8bit\n"
+
+#: ../../source/quick-start.rst:2
+msgid "Quick Start"
+msgstr "Быстрый старт"
+
+#: ../../source/quick-start.rst:5
+msgid "Prerequisites"
+msgstr "Предварительные требования"
+
+#: ../../source/quick-start.rst:7
+msgid "Python 3.11+"
+msgstr "Python 3.11+"
+
+#: ../../source/quick-start.rst:8
+msgid "Reticulum Network Stack (:code:`pip install rns`)"
+msgstr "Сетевой стек Reticulum (:code:`pip install rns`)"
+
+#: ../../source/quick-start.rst:9
+msgid "LXMFy (:code:`pip install lxmfy` or install from source)"
+msgstr "LXMFy (:code:`pip install lxmfy` или установка из исходного кода)"
+
+#: ../../source/quick-start.rst:12
+msgid "Creating Your First Bot (Using the CLI)"
+msgstr "Создание вашего первого бота (с помощью CLI)"
+
+#: ../../source/quick-start.rst:14
+msgid "The easiest way to start is using the LXMFy command-line tool."
+msgstr ""
+"Самый простой способ начать - использовать инструмент командной строки LXMFy."
+
+#: ../../source/quick-start.rst:16
+msgid ""
+"**Open your terminal** in the directory where you want to create your bot "
+"project."
+msgstr ""
+"**Откройте терминал** в каталоге, где вы хотите создать проект своего бота."
+
+#: ../../source/quick-start.rst:17
+msgid "**Run the create command:**"
+msgstr "**Выполните команду create:**"
+
+#: ../../source/quick-start.rst:23
+msgid ""
+"This command will generate the following files: * :code:`my_first_bot.py`: "
+"Your main bot file, configured with sensible defaults. * :code:`cogs/`: A "
+"directory for bot extensions (cogs). * :code:`cogs/__init__.py`: Makes "
+"the :code:`cogs` directory a Python package. * :code:`cogs/basic.py`: An "
+"example cog with simple \"hello\" and \"about\" commands. * :code:`data/`: "
+"A directory where the bot will store its data (using JSON by default). "
+"* :code:`config/`: A directory where the bot stores its identity and "
+"announce status."
+msgstr ""
+"Эта команда сгенерирует следующие файлы: * :code:`my_first_bot.py`: Ваш "
+"основной файл бота, настроенный с разумными значениями по умолчанию. "
+"* :code:`cogs/`: Каталог для расширений бота (модулей). * :code:`cogs/"
+"__init__.py`: Делает каталог :code:`cogs` пакетом Python. * :code:`cogs/"
+"basic.py`: Пример модуля с простыми командами \"hello\" и \"about\". "
+"* :code:`data/`: Каталог, в котором бот будет хранить свои данные (по "
+"умолчанию используется JSON). * :code:`config/`: Каталог, в котором бот "
+"хранит свою идентификацию и статус объявления."
+
+#: ../../source/quick-start.rst:31
+msgid "**Review the :code:`my_first_bot.py` file:**"
+msgstr "**Просмотрите файл :code:`my_first_bot.py`:**"
+
+#: ../../source/quick-start.rst:80
+msgid ""
+"**(Optional) Add Your Admin Hash:** * Find your LXMF address hash (e.g., "
+"from your Reticulum client like Sideband or NomadNet). * Uncomment and "
+"edit the :code:`bot.config.admins.add(...)` line in :code:`my_first_bot.py`, "
+"replacing :code:`\"your_lxmf_hash_here\"` with your actual hash."
+msgstr ""
+"**(Необязательно) Добавьте свой хэш администратора:** * Найдите хэш своего "
+"адреса LXMF (например, в вашем клиенте Reticulum, таком как Sideband или "
+"NomadNet). * Раскомментируйте и отредактируйте "
+"строку :code:`bot.config.admins.add(...)` в файле :code:`my_first_bot.py`, "
+"заменив :code:`\"your_lxmf_hash_here\"` на ваш фактический хэш."
+
+#: ../../source/quick-start.rst:84
+msgid "**Run Your Bot:**"
+msgstr "**Запустите своего бота:**"
+
+#: ../../source/quick-start.rst:90
+msgid ""
+"Your bot will start, print its LXMF address, potentially send an announce "
+"message over the Reticulum network, and begin listening for messages."
+msgstr ""
+"Ваш бот запустится, выведет свой адрес LXMF, потенциально отправит "
+"объявление по сети Reticulum и начнет прослушивать сообщения."
+
+#: ../../source/quick-start.rst:93
+msgid "Interacting With Your Bot"
+msgstr "Взаимодействие с вашим ботом"
+
+#: ../../source/quick-start.rst:95
+msgid "**Send a message** to the bot's LXMF address from your client."
+msgstr "**Отправьте сообщение** на адрес LXMF бота из вашего клиента."
+
+#: ../../source/quick-start.rst:96
+msgid ""
+"**Try the example command:** Send :code:`/hello` to the bot. It should reply "
+"with \"Hello :code:`<your_hash>`!\". If you uncommented the icon example "
+"above, this reply might also carry an icon."
+msgstr ""
+"**Попробуйте пример команды:** Отправьте :code:`/hello` боту. Он должен "
+"ответить \"Hello :code:`<your_hash>`!\". Если вы раскомментировали пример с "
+"иконкой выше, этот ответ также может содержать иконку."
+
+#: ../../source/quick-start.rst:98
+msgid "**Try the help command:** Send :code:`/help`."
+msgstr "**Попробуйте команду помощи:** Отправьте :code:`/help`."
+
+#: ../../source/quick-start.rst:101
+msgid "Advanced Features"
+msgstr "Расширенные возможности"
+
+#: ../../source/quick-start.rst:103
+msgid ""
+"Once you're comfortable with the basics, explore these advanced features:"
+msgstr "Когда вы освоитесь с основами, изучите эти расширенные возможности:"
+
+#: ../../source/quick-start.rst:105
+msgid "**Message Handlers:**"
+msgstr "**Обработчики сообщений:**"
+
+#: ../../source/quick-start.rst:107
+msgid "Use :code:`@bot.on_first_message()` to welcome new users"
+msgstr "Используйте :code:`@bot.on_first_message()` для приветствия новых пользователей"
+
+#: ../../source/quick-start.rst:108
+msgid ""
+"Use :code:`@bot.on_message()` to handle all messages before command "
+"processing"
+msgstr "Используйте :code:`@bot.on_message()` для обработки всех сообщений перед обработкой команд"
+
+#: ../../source/quick-start.rst:110
+msgid "**Reliable Delivery:**"
+msgstr "**Надежная доставка:**"
+
+#: ../../source/quick-start.rst:112
+msgid ""
+"Configure :code:`max_retries` parameter in :code:`bot.send()` for automatic "
+"retry on delivery failure"
+msgstr "Настройте параметр :code:`max_retries` в :code:`bot.send()` для автоматического повтора при сбое доставки"
+
+#: ../../source/quick-start.rst:113
+msgid ""
+"Use :code:`propagation_node` parameter to route messages through specific "
+"LXMF propagation nodes"
+msgstr "Используйте параметр :code:`propagation_node` для маршрутизации сообщений через определенные узлы распространения LXMF"
+
+#: ../../source/quick-start.rst:115
+msgid "**Security:**"
+msgstr "**Безопасность:**"
+
+#: ../../source/quick-start.rst:117
+msgid ""
+"Enable :code:`signature_verification_enabled=True` to enforce LXMF's built-"
+"in signature verification"
+msgstr "Включите :code:`signature_verification_enabled=True` для принудительной встроенной проверки подписи LXMF"
+
+#: ../../source/quick-start.rst:118
+msgid ""
+"Set :code:`require_message_signatures=True` to reject unsigned or invalid "
+"messages"
+msgstr "Установите :code:`require_message_signatures=True` для отклонения неподписанных или недействительных сообщений"
+
+#: ../../source/quick-start.rst:119
+msgid ""
+"Note: LXMF automatically signs all messages; LXMFy just enforces "
+"verification policy"
+msgstr "Примечание: LXMF автоматически подписывает все сообщения; LXMFy только применяет политику проверки"
+
+#: ../../source/quick-start.rst:121
+#, fuzzy
+msgid ""
+"See the `Creating Bots <creating-bots.html>`_ guide and `API Reference <api-"
+"reference.html>`_ for detailed information on these features."
+msgstr ""
+"См. руководство `Создание ботов <creating-bots.html>`_ и `Справочник по API "
+"<api-reference.html>`_ для получения подробной информации об этих функциях."
+
+#: ../../source/quick-start.rst:124
+msgid "Next Steps"
+msgstr "Следующие шаги"
+
+#: ../../source/quick-start.rst:126
+msgid ""
+"Explore the `Creating Bots <creating-bots.html>`_ guide for more details on "
+"adding commands, using cogs, and different bot types."
+msgstr ""
+"Изучите руководство `Создание ботов <creating-bots.html>`_, чтобы получить "
+"более подробную информацию о добавлении команд, использовании модулей и "
+"различных типах ботов."
+
+#: ../../source/quick-start.rst:127
+msgid ""
+"Check the `API Reference <api-reference.html>`_ for detailed information on "
+"framework components."
+msgstr ""
+"Проверьте `Справочник по API <api-reference.html>`_ для получения подробной "
+"информации о компонентах фреймворка."

diff --git a/vendor/lxmfy/docs/make.bat b/vendor/lxmfy/docs/make.bat
new file mode 100644
index 00000000..747ffb7b
--- /dev/null
+++ b/vendor/lxmfy/docs/make.bat
@@ -0,0 +1,35 @@
+@ECHO OFF
+
+pushd %~dp0
+
+REM Command file for Sphinx documentation
+
+if "%SPHINXBUILD%" == "" (
+ set SPHINXBUILD=sphinx-build
+)
+set SOURCEDIR=source
+set BUILDDIR=build
+
+%SPHINXBUILD% >NUL 2>NUL
+if errorlevel 9009 (
+ echo.
+ echo.The 'sphinx-build' command was not found. Make sure you have Sphinx
+ echo.installed, then set the SPHINXBUILD environment variable to point
+ echo.to the full path of the 'sphinx-build' executable. Alternatively you
+ echo.may add the Sphinx directory to PATH.
+ echo.
+ echo.If you don't have Sphinx installed, grab it from
+ echo.https://www.sphinx-doc.org/
+ exit /b 1
+)
+
+if "%1" == "" goto help
+
+%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O%
+goto end
+
+:help
+%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O%
+
+:end
+popd

diff --git a/vendor/lxmfy/docs/nginx.conf b/vendor/lxmfy/docs/nginx.conf
new file mode 100644
index 00000000..67369447
--- /dev/null
+++ b/vendor/lxmfy/docs/nginx.conf
@@ -0,0 +1,86 @@
+pid /var/run/nginx/nginx.pid;
+
+events {
+ worker_connections 1024;
+}
+
+http {
+ include /etc/nginx/mime.types;
+ default_type application/octet-stream;
+
+ log_format main '$remote_addr - $remote_user [$time_local] "$request" '
+ '$status $body_bytes_sent "$http_referer" '
+ '"$http_user_agent" "$http_x_forwarded_for"';
+
+ access_log /var/log/nginx/access.log main;
+ error_log /var/log/nginx/error.log warn;
+
+ # Set client body and proxy temp paths for tmpfs compatibility
+ client_body_temp_path /tmp/nginx_client_temp;
+ proxy_temp_path /tmp/nginx_proxy_temp;
+ fastcgi_temp_path /tmp/nginx_fastcgi_temp;
+ uwsgi_temp_path /tmp/nginx_uwsgi_temp;
+ scgi_temp_path /tmp/nginx_scgi_temp;
+
+ sendfile on;
+ tcp_nopush on;
+ tcp_nodelay on;
+ keepalive_timeout 65;
+ types_hash_max_size 2048;
+ server_tokens off;
+
+ gzip on;
+ gzip_vary on;
+ gzip_min_length 1024;
+ gzip_proxied any;
+ gzip_comp_level 6;
+ gzip_types
+ application/atom+xml
+ application/geo+json
+ application/javascript
+ application/x-javascript
+ application/json
+ application/ld+json
+ application/manifest+json
+ application/rdf+xml
+ application/rss+xml
+ application/xhtml+xml
+ application/xml
+ font/eot
+ font/otf
+ font/ttf
+ image/svg+xml
+ text/css
+ text/javascript
+ text/plain
+ text/xml;
+
+ server {
+ listen 8080;
+ server_name localhost;
+ root /usr/share/nginx/html;
+ index index.html;
+
+ add_header X-Frame-Options DENY always;
+ add_header X-Content-Type-Options nosniff always;
+ add_header X-XSS-Protection "1; mode=block" always;
+ add_header Referrer-Policy "strict-origin-when-cross-origin" always;
+
+ location / {
+ try_files $uri $uri/ $uri.html =404;
+ }
+
+ location ~* \.(css|js|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
+ expires 1y;
+ add_header Cache-Control "public, immutable";
+ }
+
+ location ~* \.html$ {
+ expires 1h;
+ add_header Cache-Control "public, must-revalidate";
+ }
+
+ error_page 404 /404.html;
+ error_page 500 502 503 504 /50x.html;
+ }
+}

diff --git a/vendor/lxmfy/docs/poetry.lock b/vendor/lxmfy/docs/poetry.lock
new file mode 100644
index 00000000..a43d69fe
--- /dev/null
+++ b/vendor/lxmfy/docs/poetry.lock
@@ -0,0 +1,609 @@
+# This file is automatically @generated by Poetry 2.1.2 and should not be changed by hand.
+
+[[package]]
+name = "accessible-pygments"
+version = "0.0.5"
+description = "A collection of accessible pygments styles"
+optional = false
+python-versions = ">=3.9"
+groups = ["dev"]
+files = [
+ {file = "accessible_pygments-0.0.5-py3-none-any.whl", hash = "sha256:88ae3211e68a1d0b011504b2ffc1691feafce124b845bd072ab6f9f66f34d4b7"},
+ {file = "accessible_pygments-0.0.5.tar.gz", hash = "sha256:40918d3e6a2b619ad424cb91e556bd3bd8865443d9f22f1dcdf79e33c8046872"},
+]
+
+[package.dependencies]
+pygments = ">=1.5"
+
+[package.extras]
+dev = ["pillow", "pkginfo (>=1.10)", "playwright", "pre-commit", "setuptools", "twine (>=5.0)"]
+tests = ["hypothesis", "pytest"]
+
+[[package]]
+name = "alabaster"
+version = "1.0.0"
+description = "A light, configurable Sphinx theme"
+optional = false
+python-versions = ">=3.10"
+groups = ["dev"]
+files = [
+ {file = "alabaster-1.0.0-py3-none-any.whl", hash = "sha256:fc6786402dc3fcb2de3cabd5fe455a2db534b371124f1f21de8731783dec828b"},
+ {file = "alabaster-1.0.0.tar.gz", hash = "sha256:c00dca57bca26fa62a6d7d0a9fcce65f3e026e9bfe33e9c538fd3fbb2144fd9e"},
+]
+
+[[package]]
+name = "babel"
+version = "2.17.0"
+description = "Internationalization utilities"
+optional = false
+python-versions = ">=3.8"
+groups = ["dev"]
+files = [
+ {file = "babel-2.17.0-py3-none-any.whl", hash = "sha256:4d0b53093fdfb4b21c92b5213dba5a1b23885afa8383709427046b21c366e5f2"},
+ {file = "babel-2.17.0.tar.gz", hash = "sha256:0c54cffb19f690cdcc52a3b50bcbf71e07a808d1c80d549f2459b9d2cf0afb9d"},
+]
+
+[package.extras]
+dev = ["backports.zoneinfo ; python_version < \"3.9\"", "freezegun (>=1.0,<2.0)", "jinja2 (>=3.0)", "pytest (>=6.0)", "pytest-cov", "pytz", "setuptools", "tzdata ; sys_platform == \"win32\""]
+
+[[package]]
+name = "beautifulsoup4"
+version = "4.13.5"
+description = "Screen-scraping library"
+optional = false
+python-versions = ">=3.7.0"
+groups = ["dev"]
+files = [
+ {file = "beautifulsoup4-4.13.5-py3-none-any.whl", hash = "sha256:642085eaa22233aceadff9c69651bc51e8bf3f874fb6d7104ece2beb24b47c4a"},
+ {file = "beautifulsoup4-4.13.5.tar.gz", hash = "sha256:5e70131382930e7c3de33450a2f54a63d5e4b19386eab43a5b34d594268f3695"},
+]
+
+[package.dependencies]
+soupsieve = ">1.2"
+typing-extensions = ">=4.0.0"
+
+[package.extras]
+cchardet = ["cchardet"]
+chardet = ["chardet"]
+charset-normalizer = ["charset-normalizer"]
+html5lib = ["html5lib"]
+lxml = ["lxml"]
+
+[[package]]
+name = "certifi"
+version = "2025.8.3"
+description = "Python package for providing Mozilla's CA Bundle."
+optional = false
+python-versions = ">=3.7"
+groups = ["dev"]
+files = [
+ {file = "certifi-2025.8.3-py3-none-any.whl", hash = "sha256:f6c12493cfb1b06ba2ff328595af9350c65d6644968e5d3a2ffd78699af217a5"},
+ {file = "certifi-2025.8.3.tar.gz", hash = "sha256:e564105f78ded564e3ae7c923924435e1daa7463faeab5bb932bc53ffae63407"},
+]
+
+[[package]]
+name = "charset-normalizer"
+version = "3.4.3"
+description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet."
+optional = false
+python-versions = ">=3.7"
+groups = ["dev"]
+files = [
+ {file = "charset_normalizer-3.4.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:fb7f67a1bfa6e40b438170ebdc8158b78dc465a5a67b6dde178a46987b244a72"},
+ {file = "charset_normalizer-3.4.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cc9370a2da1ac13f0153780040f465839e6cccb4a1e44810124b4e22483c93fe"},
+ {file = "charset_normalizer-3.4.3-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:07a0eae9e2787b586e129fdcbe1af6997f8d0e5abaa0bc98c0e20e124d67e601"},
+ {file = "charset_normalizer-3.4.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:74d77e25adda8581ffc1c720f1c81ca082921329452eba58b16233ab1842141c"},
+ {file = "charset_normalizer-3.4.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d0e909868420b7049dafd3a31d45125b31143eec59235311fc4c57ea26a4acd2"},
+ {file = "charset_normalizer-3.4.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:c6f162aabe9a91a309510d74eeb6507fab5fff92337a15acbe77753d88d9dcf0"},
+ {file = "charset_normalizer-3.4.3-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:4ca4c094de7771a98d7fbd67d9e5dbf1eb73efa4f744a730437d8a3a5cf994f0"},
+ {file = "charset_normalizer-3.4.3-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:02425242e96bcf29a49711b0ca9f37e451da7c70562bc10e8ed992a5a7a25cc0"},
+ {file = "charset_normalizer-3.4.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:78deba4d8f9590fe4dae384aeff04082510a709957e968753ff3c48399f6f92a"},
+ {file = "charset_normalizer-3.4.3-cp310-cp310-win32.whl", hash = "sha256:d79c198e27580c8e958906f803e63cddb77653731be08851c7df0b1a14a8fc0f"},
+ {file = "charset_normalizer-3.4.3-cp310-cp310-win_amd64.whl", hash = "sha256:c6e490913a46fa054e03699c70019ab869e990270597018cef1d8562132c2669"},
+ {file = "charset_normalizer-3.4.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:b256ee2e749283ef3ddcff51a675ff43798d92d746d1a6e4631bf8c707d22d0b"},
+ {file = "charset_normalizer-3.4.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:13faeacfe61784e2559e690fc53fa4c5ae97c6fcedb8eb6fb8d0a15b475d2c64"},
+ {file = "charset_normalizer-3.4.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:00237675befef519d9af72169d8604a067d92755e84fe76492fef5441db05b91"},
+ {file = "charset_normalizer-3.4.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:585f3b2a80fbd26b048a0be90c5aae8f06605d3c92615911c3a2b03a8a3b796f"},
+ {file = "charset_normalizer-3.4.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e78314bdc32fa80696f72fa16dc61168fda4d6a0c014e0380f9d02f0e5d8a07"},
+ {file = "charset_normalizer-3.4.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:96b2b3d1a83ad55310de8c7b4a2d04d9277d5591f40761274856635acc5fcb30"},
+ {file = "charset_normalizer-3.4.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:939578d9d8fd4299220161fdd76e86c6a251987476f5243e8864a7844476ba14"},
+ {file = "charset_normalizer-3.4.3-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:fd10de089bcdcd1be95a2f73dbe6254798ec1bda9f450d5828c96f93e2536b9c"},
+ {file = "charset_normalizer-3.4.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1e8ac75d72fa3775e0b7cb7e4629cec13b7514d928d15ef8ea06bca03ef01cae"},
+ {file = "charset_normalizer-3.4.3-cp311-cp311-win32.whl", hash = "sha256:6cf8fd4c04756b6b60146d98cd8a77d0cdae0e1ca20329da2ac85eed779b6849"},
+ {file = "charset_normalizer-3.4.3-cp311-cp311-win_amd64.whl", hash = "sha256:31a9a6f775f9bcd865d88ee350f0ffb0e25936a7f930ca98995c05abf1faf21c"},
+ {file = "charset_normalizer-3.4.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e28e334d3ff134e88989d90ba04b47d84382a828c061d0d1027b1b12a62b39b1"},
+ {file = "charset_normalizer-3.4.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0cacf8f7297b0c4fcb74227692ca46b4a5852f8f4f24b3c766dd94a1075c4884"},
+ {file = "charset_normalizer-3.4.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c6fd51128a41297f5409deab284fecbe5305ebd7e5a1f959bee1c054622b7018"},
+ {file = "charset_normalizer-3.4.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3cfb2aad70f2c6debfbcb717f23b7eb55febc0bb23dcffc0f076009da10c6392"},
+ {file = "charset_normalizer-3.4.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1606f4a55c0fd363d754049cdf400175ee96c992b1f8018b993941f221221c5f"},
+ {file = "charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:027b776c26d38b7f15b26a5da1044f376455fb3766df8fc38563b4efbc515154"},
+ {file = "charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:42e5088973e56e31e4fa58eb6bd709e42fc03799c11c42929592889a2e54c491"},
+ {file = "charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:cc34f233c9e71701040d772aa7490318673aa7164a0efe3172b2981218c26d93"},
+ {file = "charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:320e8e66157cc4e247d9ddca8e21f427efc7a04bbd0ac8a9faf56583fa543f9f"},
+ {file = "charset_normalizer-3.4.3-cp312-cp312-win32.whl", hash = "sha256:fb6fecfd65564f208cbf0fba07f107fb661bcd1a7c389edbced3f7a493f70e37"},
+ {file = "charset_normalizer-3.4.3-cp312-cp312-win_amd64.whl", hash = "sha256:86df271bf921c2ee3818f0522e9a5b8092ca2ad8b065ece5d7d9d0e9f4849bcc"},
+ {file = "charset_normalizer-3.4.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:14c2a87c65b351109f6abfc424cab3927b3bdece6f706e4d12faaf3d52ee5efe"},
+ {file = "charset_normalizer-3.4.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:41d1fc408ff5fdfb910200ec0e74abc40387bccb3252f3f27c0676731df2b2c8"},
+ {file = "charset_normalizer-3.4.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1bb60174149316da1c35fa5233681f7c0f9f514509b8e399ab70fea5f17e45c9"},
+ {file = "charset_normalizer-3.4.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:30d006f98569de3459c2fc1f2acde170b7b2bd265dc1943e87e1a4efe1b67c31"},
+ {file = "charset_normalizer-3.4.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:416175faf02e4b0810f1f38bcb54682878a4af94059a1cd63b8747244420801f"},
+ {file = "charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6aab0f181c486f973bc7262a97f5aca3ee7e1437011ef0c2ec04b5a11d16c927"},
+ {file = "charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:fdabf8315679312cfa71302f9bd509ded4f2f263fb5b765cf1433b39106c3cc9"},
+ {file = "charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:bd28b817ea8c70215401f657edef3a8aa83c29d447fb0b622c35403780ba11d5"},
+ {file = "charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:18343b2d246dc6761a249ba1fb13f9ee9a2bcd95decc767319506056ea4ad4dc"},
+ {file = "charset_normalizer-3.4.3-cp313-cp313-win32.whl", hash = "sha256:6fb70de56f1859a3f71261cbe41005f56a7842cc348d3aeb26237560bfa5e0ce"},
+ {file = "charset_normalizer-3.4.3-cp313-cp313-win_amd64.whl", hash = "sha256:cf1ebb7d78e1ad8ec2a8c4732c7be2e736f6e5123a4146c5b89c9d1f585f8cef"},
+ {file = "charset_normalizer-3.4.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3cd35b7e8aedeb9e34c41385fda4f73ba609e561faedfae0a9e75e44ac558a15"},
+ {file = "charset_normalizer-3.4.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b89bc04de1d83006373429975f8ef9e7932534b8cc9ca582e4db7d20d91816db"},
+ {file = "charset_normalizer-3.4.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2001a39612b241dae17b4687898843f254f8748b796a2e16f1051a17078d991d"},
+ {file = "charset_normalizer-3.4.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8dcfc373f888e4fb39a7bc57e93e3b845e7f462dacc008d9749568b1c4ece096"},
+ {file = "charset_normalizer-3.4.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18b97b8404387b96cdbd30ad660f6407799126d26a39ca65729162fd810a99aa"},
+ {file = "charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ccf600859c183d70eb47e05a44cd80a4ce77394d1ac0f79dbd2dd90a69a3a049"},
+ {file = "charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:53cd68b185d98dde4ad8990e56a58dea83a4162161b1ea9272e5c9182ce415e0"},
+ {file = "charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:30a96e1e1f865f78b030d65241c1ee850cdf422d869e9028e2fc1d5e4db73b92"},
+ {file = "charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d716a916938e03231e86e43782ca7878fb602a125a91e7acb8b5112e2e96ac16"},
+ {file = "charset_normalizer-3.4.3-cp314-cp314-win32.whl", hash = "sha256:c6dbd0ccdda3a2ba7c2ecd9d77b37f3b5831687d8dc1b6ca5f56a4880cc7b7ce"},
+ {file = "charset_normalizer-3.4.3-cp314-cp314-win_amd64.whl", hash = "sha256:73dc19b562516fc9bcf6e5d6e596df0b4eb98d87e4f79f3ae71840e6ed21361c"},
+ {file = "charset_normalizer-3.4.3-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:0f2be7e0cf7754b9a30eb01f4295cc3d4358a479843b31f328afd210e2c7598c"},
+ {file = "charset_normalizer-3.4.3-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c60e092517a73c632ec38e290eba714e9627abe9d301c8c8a12ec32c314a2a4b"},
+ {file = "charset_normalizer-3.4.3-cp38-cp38-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:252098c8c7a873e17dd696ed98bbe91dbacd571da4b87df3736768efa7a792e4"},
+ {file = "charset_normalizer-3.4.3-cp38-cp38-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3653fad4fe3ed447a596ae8638b437f827234f01a8cd801842e43f3d0a6b281b"},
+ {file = "charset_normalizer-3.4.3-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8999f965f922ae054125286faf9f11bc6932184b93011d138925a1773830bbe9"},
+ {file = "charset_normalizer-3.4.3-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:d95bfb53c211b57198bb91c46dd5a2d8018b3af446583aab40074bf7988401cb"},
+ {file = "charset_normalizer-3.4.3-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:5b413b0b1bfd94dbf4023ad6945889f374cd24e3f62de58d6bb102c4d9ae534a"},
+ {file = "charset_normalizer-3.4.3-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:b5e3b2d152e74e100a9e9573837aba24aab611d39428ded46f4e4022ea7d1942"},
+ {file = "charset_normalizer-3.4.3-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:a2d08ac246bb48479170408d6c19f6385fa743e7157d716e144cad849b2dd94b"},
+ {file = "charset_normalizer-3.4.3-cp38-cp38-win32.whl", hash = "sha256:ec557499516fc90fd374bf2e32349a2887a876fbf162c160e3c01b6849eaf557"},
+ {file = "charset_normalizer-3.4.3-cp38-cp38-win_amd64.whl", hash = "sha256:5d8d01eac18c423815ed4f4a2ec3b439d654e55ee4ad610e153cf02faf67ea40"},
+ {file = "charset_normalizer-3.4.3-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:70bfc5f2c318afece2f5838ea5e4c3febada0be750fcf4775641052bbba14d05"},
+ {file = "charset_normalizer-3.4.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:23b6b24d74478dc833444cbd927c338349d6ae852ba53a0d02a2de1fce45b96e"},
+ {file = "charset_normalizer-3.4.3-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:34a7f768e3f985abdb42841e20e17b330ad3aaf4bb7e7aeeb73db2e70f077b99"},
+ {file = "charset_normalizer-3.4.3-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fb731e5deb0c7ef82d698b0f4c5bb724633ee2a489401594c5c88b02e6cb15f7"},
+ {file = "charset_normalizer-3.4.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:257f26fed7d7ff59921b78244f3cd93ed2af1800ff048c33f624c87475819dd7"},
+ {file = "charset_normalizer-3.4.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:1ef99f0456d3d46a50945c98de1774da86f8e992ab5c77865ea8b8195341fc19"},
+ {file = "charset_normalizer-3.4.3-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:2c322db9c8c89009a990ef07c3bcc9f011a3269bc06782f916cd3d9eed7c9312"},
+ {file = "charset_normalizer-3.4.3-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:511729f456829ef86ac41ca78c63a5cb55240ed23b4b737faca0eb1abb1c41bc"},
+ {file = "charset_normalizer-3.4.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:88ab34806dea0671532d3f82d82b85e8fc23d7b2dd12fa837978dad9bb392a34"},
+ {file = "charset_normalizer-3.4.3-cp39-cp39-win32.whl", hash = "sha256:16a8770207946ac75703458e2c743631c79c59c5890c80011d536248f8eaa432"},
+ {file = "charset_normalizer-3.4.3-cp39-cp39-win_amd64.whl", hash = "sha256:d22dbedd33326a4a5190dd4fe9e9e693ef12160c77382d9e87919bce54f3d4ca"},
+ {file = "charset_normalizer-3.4.3-py3-none-any.whl", hash = "sha256:ce571ab16d890d23b5c278547ba694193a45011ff86a9162a71307ed9f86759a"},
+ {file = "charset_normalizer-3.4.3.tar.gz", hash = "sha256:6fce4b8500244f6fcb71465d4a4930d132ba9ab8e71a7859e6a5d59851068d14"},
+]
+
+[[package]]
+name = "colorama"
+version = "0.4.6"
+description = "Cross-platform colored terminal text."
+optional = false
+python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7"
+groups = ["dev"]
+markers = "sys_platform == \"win32\""
+files = [
+ {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"},
+ {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"},
+]
+
+[[package]]
+name = "docutils"
+version = "0.21.2"
+description = "Docutils -- Python Documentation Utilities"
+optional = false
+python-versions = ">=3.9"
+groups = ["dev"]
+files = [
+ {file = "docutils-0.21.2-py3-none-any.whl", hash = "sha256:dafca5b9e384f0e419294eb4d2ff9fa826435bf15f15b7bd45723e8ad76811b2"},
+ {file = "docutils-0.21.2.tar.gz", hash = "sha256:3a6b18732edf182daa3cd12775bbb338cf5691468f91eeeb109deff6ebfa986f"},
+]
+
+[[package]]
+name = "furo"
+version = "2025.7.19"
+description = "A clean customisable Sphinx documentation theme."
+optional = false
+python-versions = ">=3.8"
+groups = ["dev"]
+files = [
+ {file = "furo-2025.7.19-py3-none-any.whl", hash = "sha256:bdea869822dfd2b494ea84c0973937e35d1575af088b6721a29c7f7878adc9e3"},
+ {file = "furo-2025.7.19.tar.gz", hash = "sha256:4164b2cafcf4023a59bb3c594e935e2516f6b9d35e9a5ea83d8f6b43808fe91f"},
+]
+
+[package.dependencies]
+accessible-pygments = ">=0.0.5"
+beautifulsoup4 = "*"
+pygments = ">=2.7"
+sphinx = ">=6.0,<9.0"
+sphinx-basic-ng = ">=1.0.0.beta2"
+
+[[package]]
+name = "idna"
+version = "3.10"
+description = "Internationalized Domain Names in Applications (IDNA)"
+optional = false
+python-versions = ">=3.6"
+groups = ["dev"]
+files = [
+ {file = "idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3"},
+ {file = "idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9"},
+]
+
+[package.extras]
+all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"]
+
+[[package]]
+name = "imagesize"
+version = "1.4.1"
+description = "Getting image size from png/jpeg/jpeg2000/gif file"
+optional = false
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
+groups = ["dev"]
+files = [
+ {file = "imagesize-1.4.1-py2.py3-none-any.whl", hash = "sha256:0d8d18d08f840c19d0ee7ca1fd82490fdc3729b7ac93f49870406ddde8ef8d8b"},
+ {file = "imagesize-1.4.1.tar.gz", hash = "sha256:69150444affb9cb0d5cc5a92b3676f0b2fb7cd9ae39e947a5e11a36b4497cd4a"},
+]
+
+[[package]]
+name = "jinja2"
+version = "3.1.6"
+description = "A very fast and expressive template engine."
+optional = false
+python-versions = ">=3.7"
+groups = ["dev"]
+files = [
+ {file = "jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67"},
+ {file = "jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d"},
+]
+
+[package.dependencies]
+MarkupSafe = ">=2.0"
+
+[package.extras]
+i18n = ["Babel (>=2.7)"]
+
+[[package]]
+name = "markupsafe"
+version = "3.0.2"
+description = "Safely add untrusted strings to HTML/XML markup."
+optional = false
+python-versions = ">=3.9"
+groups = ["dev"]
+files = [
+ {file = "MarkupSafe-3.0.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7e94c425039cde14257288fd61dcfb01963e658efbc0ff54f5306b06054700f8"},
+ {file = "MarkupSafe-3.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9e2d922824181480953426608b81967de705c3cef4d1af983af849d7bd619158"},
+ {file = "MarkupSafe-3.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:38a9ef736c01fccdd6600705b09dc574584b89bea478200c5fbf112a6b0d5579"},
+ {file = "MarkupSafe-3.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bbcb445fa71794da8f178f0f6d66789a28d7319071af7a496d4d507ed566270d"},
+ {file = "MarkupSafe-3.0.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:57cb5a3cf367aeb1d316576250f65edec5bb3be939e9247ae594b4bcbc317dfb"},
+ {file = "MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:3809ede931876f5b2ec92eef964286840ed3540dadf803dd570c3b7e13141a3b"},
+ {file = "MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e07c3764494e3776c602c1e78e298937c3315ccc9043ead7e685b7f2b8d47b3c"},
+ {file = "MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b424c77b206d63d500bcb69fa55ed8d0e6a3774056bdc4839fc9298a7edca171"},
+ {file = "MarkupSafe-3.0.2-cp310-cp310-win32.whl", hash = "sha256:fcabf5ff6eea076f859677f5f0b6b5c1a51e70a376b0579e0eadef8db48c6b50"},
+ {file = "MarkupSafe-3.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:6af100e168aa82a50e186c82875a5893c5597a0c1ccdb0d8b40240b1f28b969a"},
+ {file = "MarkupSafe-3.0.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9025b4018f3a1314059769c7bf15441064b2207cb3f065e6ea1e7359cb46db9d"},
+ {file = "MarkupSafe-3.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:93335ca3812df2f366e80509ae119189886b0f3c2b81325d39efdb84a1e2ae93"},
+ {file = "MarkupSafe-3.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2cb8438c3cbb25e220c2ab33bb226559e7afb3baec11c4f218ffa7308603c832"},
+ {file = "MarkupSafe-3.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a123e330ef0853c6e822384873bef7507557d8e4a082961e1defa947aa59ba84"},
+ {file = "MarkupSafe-3.0.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1e084f686b92e5b83186b07e8a17fc09e38fff551f3602b249881fec658d3eca"},
+ {file = "MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d8213e09c917a951de9d09ecee036d5c7d36cb6cb7dbaece4c71a60d79fb9798"},
+ {file = "MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:5b02fb34468b6aaa40dfc198d813a641e3a63b98c2b05a16b9f80b7ec314185e"},
+ {file = "MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0bff5e0ae4ef2e1ae4fdf2dfd5b76c75e5c2fa4132d05fc1b0dabcd20c7e28c4"},
+ {file = "MarkupSafe-3.0.2-cp311-cp311-win32.whl", hash = "sha256:6c89876f41da747c8d3677a2b540fb32ef5715f97b66eeb0c6b66f5e3ef6f59d"},
+ {file = "MarkupSafe-3.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:70a87b411535ccad5ef2f1df5136506a10775d267e197e4cf531ced10537bd6b"},
+ {file = "MarkupSafe-3.0.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:9778bd8ab0a994ebf6f84c2b949e65736d5575320a17ae8984a77fab08db94cf"},
+ {file = "MarkupSafe-3.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:846ade7b71e3536c4e56b386c2a47adf5741d2d8b94ec9dc3e92e5e1ee1e2225"},
+ {file = "MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1c99d261bd2d5f6b59325c92c73df481e05e57f19837bdca8413b9eac4bd8028"},
+ {file = "MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e17c96c14e19278594aa4841ec148115f9c7615a47382ecb6b82bd8fea3ab0c8"},
+ {file = "MarkupSafe-3.0.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:88416bd1e65dcea10bc7569faacb2c20ce071dd1f87539ca2ab364bf6231393c"},
+ {file = "MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2181e67807fc2fa785d0592dc2d6206c019b9502410671cc905d132a92866557"},
+ {file = "MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:52305740fe773d09cffb16f8ed0427942901f00adedac82ec8b67752f58a1b22"},
+ {file = "MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ad10d3ded218f1039f11a75f8091880239651b52e9bb592ca27de44eed242a48"},
+ {file = "MarkupSafe-3.0.2-cp312-cp312-win32.whl", hash = "sha256:0f4ca02bea9a23221c0182836703cbf8930c5e9454bacce27e767509fa286a30"},
+ {file = "MarkupSafe-3.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:8e06879fc22a25ca47312fbe7c8264eb0b662f6db27cb2d3bbbc74b1df4b9b87"},
+ {file = "MarkupSafe-3.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ba9527cdd4c926ed0760bc301f6728ef34d841f405abf9d4f959c478421e4efd"},
+ {file = "MarkupSafe-3.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f8b3d067f2e40fe93e1ccdd6b2e1d16c43140e76f02fb1319a05cf2b79d99430"},
+ {file = "MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:569511d3b58c8791ab4c2e1285575265991e6d8f8700c7be0e88f86cb0672094"},
+ {file = "MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15ab75ef81add55874e7ab7055e9c397312385bd9ced94920f2802310c930396"},
+ {file = "MarkupSafe-3.0.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f3818cb119498c0678015754eba762e0d61e5b52d34c8b13d770f0719f7b1d79"},
+ {file = "MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cdb82a876c47801bb54a690c5ae105a46b392ac6099881cdfb9f6e95e4014c6a"},
+ {file = "MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:cabc348d87e913db6ab4aa100f01b08f481097838bdddf7c7a84b7575b7309ca"},
+ {file = "MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:444dcda765c8a838eaae23112db52f1efaf750daddb2d9ca300bcae1039adc5c"},
+ {file = "MarkupSafe-3.0.2-cp313-cp313-win32.whl", hash = "sha256:bcf3e58998965654fdaff38e58584d8937aa3096ab5354d493c77d1fdd66d7a1"},
+ {file = "MarkupSafe-3.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:e6a2a455bd412959b57a172ce6328d2dd1f01cb2135efda2e4576e8a23fa3b0f"},
+ {file = "MarkupSafe-3.0.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:b5a6b3ada725cea8a5e634536b1b01c30bcdcd7f9c6fff4151548d5bf6b3a36c"},
+ {file = "MarkupSafe-3.0.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a904af0a6162c73e3edcb969eeeb53a63ceeb5d8cf642fade7d39e7963a22ddb"},
+ {file = "MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4aa4e5faecf353ed117801a068ebab7b7e09ffb6e1d5e412dc852e0da018126c"},
+ {file = "MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c0ef13eaeee5b615fb07c9a7dadb38eac06a0608b41570d8ade51c56539e509d"},
+ {file = "MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d16a81a06776313e817c951135cf7340a3e91e8c1ff2fac444cfd75fffa04afe"},
+ {file = "MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6381026f158fdb7c72a168278597a5e3a5222e83ea18f543112b2662a9b699c5"},
+ {file = "MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:3d79d162e7be8f996986c064d1c7c817f6df3a77fe3d6859f6f9e7be4b8c213a"},
+ {file = "MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:131a3c7689c85f5ad20f9f6fb1b866f402c445b220c19fe4308c0b147ccd2ad9"},
+ {file = "MarkupSafe-3.0.2-cp313-cp313t-win32.whl", hash = "sha256:ba8062ed2cf21c07a9e295d5b8a2a5ce678b913b45fdf68c32d95d6c1291e0b6"},
+ {file = "MarkupSafe-3.0.2-cp313-cp313t-win_amd64.whl", hash = "sha256:e444a31f8db13eb18ada366ab3cf45fd4b31e4db1236a4448f68778c1d1a5a2f"},
+ {file = "MarkupSafe-3.0.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:eaa0a10b7f72326f1372a713e73c3f739b524b3af41feb43e4921cb529f5929a"},
+ {file = "MarkupSafe-3.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:48032821bbdf20f5799ff537c7ac3d1fba0ba032cfc06194faffa8cda8b560ff"},
+ {file = "MarkupSafe-3.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1a9d3f5f0901fdec14d8d2f66ef7d035f2157240a433441719ac9a3fba440b13"},
+ {file = "MarkupSafe-3.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:88b49a3b9ff31e19998750c38e030fc7bb937398b1f78cfa599aaef92d693144"},
+ {file = "MarkupSafe-3.0.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cfad01eed2c2e0c01fd0ecd2ef42c492f7f93902e39a42fc9ee1692961443a29"},
+ {file = "MarkupSafe-3.0.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:1225beacc926f536dc82e45f8a4d68502949dc67eea90eab715dea3a21c1b5f0"},
+ {file = "MarkupSafe-3.0.2-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:3169b1eefae027567d1ce6ee7cae382c57fe26e82775f460f0b2778beaad66c0"},
+ {file = "MarkupSafe-3.0.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:eb7972a85c54febfb25b5c4b4f3af4dcc731994c7da0d8a0b4a6eb0640e1d178"},
+ {file = "MarkupSafe-3.0.2-cp39-cp39-win32.whl", hash = "sha256:8c4e8c3ce11e1f92f6536ff07154f9d49677ebaaafc32db9db4620bc11ed480f"},
+ {file = "MarkupSafe-3.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:6e296a513ca3d94054c2c881cc913116e90fd030ad1c656b3869762b754f5f8a"},
+ {file = "markupsafe-3.0.2.tar.gz", hash = "sha256:ee55d3edf80167e48ea11a923c7386f4669df67d7994554387f84e7d8b0a2bf0"},
+]
+
+[[package]]
+name = "packaging"
+version = "25.0"
+description = "Core utilities for Python packages"
+optional = false
+python-versions = ">=3.8"
+groups = ["dev"]
+files = [
+ {file = "packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484"},
+ {file = "packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f"},
+]
+
+[[package]]
+name = "pygments"
+version = "2.19.2"
+description = "Pygments is a syntax highlighting package written in Python."
+optional = false
+python-versions = ">=3.8"
+groups = ["dev"]
+files = [
+ {file = "pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b"},
+ {file = "pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887"},
+]
+
+[package.extras]
+windows-terminal = ["colorama (>=0.4.6)"]
+
+[[package]]
+name = "requests"
+version = "2.32.5"
+description = "Python HTTP for Humans."
+optional = false
+python-versions = ">=3.9"
+groups = ["dev"]
+files = [
+ {file = "requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6"},
+ {file = "requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf"},
+]
+
+[package.dependencies]
+certifi = ">=2017.4.17"
+charset_normalizer = ">=2,<4"
+idna = ">=2.5,<4"
+urllib3 = ">=1.21.1,<3"
+
+[package.extras]
+socks = ["PySocks (>=1.5.6,!=1.5.7)"]
+use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"]
+
+[[package]]
+name = "roman-numerals-py"
+version = "3.1.0"
+description = "Manipulate well-formed Roman numerals"
+optional = false
+python-versions = ">=3.9"
+groups = ["dev"]
+files = [
+ {file = "roman_numerals_py-3.1.0-py3-none-any.whl", hash = "sha256:9da2ad2fb670bcf24e81070ceb3be72f6c11c440d73bd579fbeca1e9f330954c"},
+ {file = "roman_numerals_py-3.1.0.tar.gz", hash = "sha256:be4bf804f083a4ce001b5eb7e3c0862479d10f94c936f6c4e5f250aa5ff5bd2d"},
+]
+
+[package.extras]
+lint = ["mypy (==1.15.0)", "pyright (==1.1.394)", "ruff (==0.9.7)"]
+test = ["pytest (>=8)"]
+
+[[package]]
+name = "snowballstemmer"
+version = "3.0.1"
+description = "This package provides 32 stemmers for 30 languages generated from Snowball algorithms."
+optional = false
+python-versions = "!=3.0.*,!=3.1.*,!=3.2.*"
+groups = ["dev"]
+files = [
+ {file = "snowballstemmer-3.0.1-py3-none-any.whl", hash = "sha256:6cd7b3897da8d6c9ffb968a6781fa6532dce9c3618a4b127d920dab764a19064"},
+ {file = "snowballstemmer-3.0.1.tar.gz", hash = "sha256:6d5eeeec8e9f84d4d56b847692bacf79bc2c8e90c7f80ca4444ff8b6f2e52895"},
+]
+
+[[package]]
+name = "soupsieve"
+version = "2.8"
+description = "A modern CSS selector implementation for Beautiful Soup."
+optional = false
+python-versions = ">=3.9"
+groups = ["dev"]
+files = [
+ {file = "soupsieve-2.8-py3-none-any.whl", hash = "sha256:0cc76456a30e20f5d7f2e14a98a4ae2ee4e5abdc7c5ea0aafe795f344bc7984c"},
+ {file = "soupsieve-2.8.tar.gz", hash = "sha256:e2dd4a40a628cb5f28f6d4b0db8800b8f581b65bb380b97de22ba5ca8d72572f"},
+]
+
+[[package]]
+name = "sphinx"
+version = "8.2.3"
+description = "Python documentation generator"
+optional = false
+python-versions = ">=3.11"
+groups = ["dev"]
+files = [
+ {file = "sphinx-8.2.3-py3-none-any.whl", hash = "sha256:4405915165f13521d875a8c29c8970800a0141c14cc5416a38feca4ea5d9b9c3"},
+ {file = "sphinx-8.2.3.tar.gz", hash = "sha256:398ad29dee7f63a75888314e9424d40f52ce5a6a87ae88e7071e80af296ec348"},
+]
+
+[package.dependencies]
+alabaster = ">=0.7.14"
+babel = ">=2.13"
+colorama = {version = ">=0.4.6", markers = "sys_platform == \"win32\""}
+docutils = ">=0.20,<0.22"
+imagesize = ">=1.3"
+Jinja2 = ">=3.1"
+packaging = ">=23.0"
+Pygments = ">=2.17"
+requests = ">=2.30.0"
+roman-numerals-py = ">=1.0.0"
+snowballstemmer = ">=2.2"
+sphinxcontrib-applehelp = ">=1.0.7"
+sphinxcontrib-devhelp = ">=1.0.6"
+sphinxcontrib-htmlhelp = ">=2.0.6"
+sphinxcontrib-jsmath = ">=1.0.1"
+sphinxcontrib-qthelp = ">=1.0.6"
+sphinxcontrib-serializinghtml = ">=1.1.9"
+
+[package.extras]
+docs = ["sphinxcontrib-websupport"]
+lint = ["betterproto (==2.0.0b6)", "mypy (==1.15.0)", "pypi-attestations (==0.0.21)", "pyright (==1.1.395)", "pytest (>=8.0)", "ruff (==0.9.9)", "sphinx-lint (>=0.9)", "types-Pillow (==10.2.0.20240822)", "types-Pygments (==2.19.0.20250219)", "types-colorama (==0.4.15.20240311)", "types-defusedxml (==0.7.0.20240218)", "types-docutils (==0.21.0.20241128)", "types-requests (==2.32.0.20241016)", "types-urllib3 (==1.26.25.14)"]
+test = ["cython (>=3.0)", "defusedxml (>=0.7.1)", "pytest (>=8.0)", "pytest-xdist[psutil] (>=3.4)", "setuptools (>=70.0)", "typing_extensions (>=4.9)"]
+
+[[package]]
+name = "sphinx-basic-ng"
+version = "1.0.0b2"
+description = "A modern skeleton for Sphinx themes."
+optional = false
+python-versions = ">=3.7"
+groups = ["dev"]
+files = [
+ {file = "sphinx_basic_ng-1.0.0b2-py3-none-any.whl", hash = "sha256:eb09aedbabfb650607e9b4b68c9d240b90b1e1be221d6ad71d61c52e29f7932b"},
+ {file = "sphinx_basic_ng-1.0.0b2.tar.gz", hash = "sha256:9ec55a47c90c8c002b5960c57492ec3021f5193cb26cebc2dc4ea226848651c9"},
+]
+
+[package.dependencies]
+sphinx = ">=4.0"
+
+[package.extras]
+docs = ["furo", "ipython", "myst-parser", "sphinx-copybutton", "sphinx-inline-tabs"]
+
+[[package]]
+name = "sphinxcontrib-applehelp"
+version = "2.0.0"
+description = "sphinxcontrib-applehelp is a Sphinx extension which outputs Apple help books"
+optional = false
+python-versions = ">=3.9"
+groups = ["dev"]
+files = [
+ {file = "sphinxcontrib_applehelp-2.0.0-py3-none-any.whl", hash = "sha256:4cd3f0ec4ac5dd9c17ec65e9ab272c9b867ea77425228e68ecf08d6b28ddbdb5"},
+ {file = "sphinxcontrib_applehelp-2.0.0.tar.gz", hash = "sha256:2f29ef331735ce958efa4734873f084941970894c6090408b079c61b2e1c06d1"},
+]
+
+[package.extras]
+lint = ["mypy", "ruff (==0.5.5)", "types-docutils"]
+standalone = ["Sphinx (>=5)"]
+test = ["pytest"]
+
+[[package]]
+name = "sphinxcontrib-devhelp"
+version = "2.0.0"
+description = "sphinxcontrib-devhelp is a sphinx extension which outputs Devhelp documents"
+optional = false
+python-versions = ">=3.9"
+groups = ["dev"]
+files = [
+ {file = "sphinxcontrib_devhelp-2.0.0-py3-none-any.whl", hash = "sha256:aefb8b83854e4b0998877524d1029fd3e6879210422ee3780459e28a1f03a8a2"},
+ {file = "sphinxcontrib_devhelp-2.0.0.tar.gz", hash = "sha256:411f5d96d445d1d73bb5d52133377b4248ec79db5c793ce7dbe59e074b4dd1ad"},
+]
+
+[package.extras]
+lint = ["mypy", "ruff (==0.5.5)", "types-docutils"]
+standalone = ["Sphinx (>=5)"]
+test = ["pytest"]
+
+[[package]]
+name = "sphinxcontrib-htmlhelp"
+version = "2.1.0"
+description = "sphinxcontrib-htmlhelp is a sphinx extension which renders HTML help files"
+optional = false
+python-versions = ">=3.9"
+groups = ["dev"]
+files = [
+ {file = "sphinxcontrib_htmlhelp-2.1.0-py3-none-any.whl", hash = "sha256:166759820b47002d22914d64a075ce08f4c46818e17cfc9470a9786b759b19f8"},
+ {file = "sphinxcontrib_htmlhelp-2.1.0.tar.gz", hash = "sha256:c9e2916ace8aad64cc13a0d233ee22317f2b9025b9cf3295249fa985cc7082e9"},
+]
+
+[package.extras]
+lint = ["mypy", "ruff (==0.5.5)", "types-docutils"]
+standalone = ["Sphinx (>=5)"]
+test = ["html5lib", "pytest"]
+
+[[package]]
+name = "sphinxcontrib-jsmath"
+version = "1.0.1"
+description = "A sphinx extension which renders display math in HTML via JavaScript"
+optional = false
+python-versions = ">=3.5"
+groups = ["dev"]
+files = [
+ {file = "sphinxcontrib-jsmath-1.0.1.tar.gz", hash = "sha256:a9925e4a4587247ed2191a22df5f6970656cb8ca2bd6284309578f2153e0c4b8"},
+ {file = "sphinxcontrib_jsmath-1.0.1-py2.py3-none-any.whl", hash = "sha256:2ec2eaebfb78f3f2078e73666b1415417a116cc848b72e5172e596c871103178"},
+]
+
+[package.extras]
+test = ["flake8", "mypy", "pytest"]
+
+[[package]]
+name = "sphinxcontrib-qthelp"
+version = "2.0.0"
+description = "sphinxcontrib-qthelp is a sphinx extension which outputs QtHelp documents"
+optional = false
+python-versions = ">=3.9"
+groups = ["dev"]
+files = [
+ {file = "sphinxcontrib_qthelp-2.0.0-py3-none-any.whl", hash = "sha256:b18a828cdba941ccd6ee8445dbe72ffa3ef8cbe7505d8cd1fa0d42d3f2d5f3eb"},
+ {file = "sphinxcontrib_qthelp-2.0.0.tar.gz", hash = "sha256:4fe7d0ac8fc171045be623aba3e2a8f613f8682731f9153bb2e40ece16b9bbab"},
+]
+
+[package.extras]
+lint = ["mypy", "ruff (==0.5.5)", "types-docutils"]
+standalone = ["Sphinx (>=5)"]
+test = ["defusedxml (>=0.7.1)", "pytest"]
+
+[[package]]
+name = "sphinxcontrib-serializinghtml"
+version = "2.0.0"
+description = "sphinxcontrib-serializinghtml is a sphinx extension which outputs \"serialized\" HTML files (json and pickle)"
+optional = false
+python-versions = ">=3.9"
+groups = ["dev"]
+files = [
+ {file = "sphinxcontrib_serializinghtml-2.0.0-py3-none-any.whl", hash = "sha256:6e2cb0eef194e10c27ec0023bfeb25badbbb5868244cf5bc5bdc04e4464bf331"},
+ {file = "sphinxcontrib_serializinghtml-2.0.0.tar.gz", hash = "sha256:e9d912827f872c029017a53f0ef2180b327c3f7fd23c87229f7a8e8b70031d4d"},
+]
+
+[package.extras]
+lint = ["mypy", "ruff (==0.5.5)", "types-docutils"]
+standalone = ["Sphinx (>=5)"]
+test = ["pytest"]
+
+[[package]]
+name = "typing-extensions"
+version = "4.15.0"
+description = "Backported and Experimental Type Hints for Python 3.9+"
+optional = false
+python-versions = ">=3.9"
+groups = ["dev"]
+files = [
+ {file = "typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"},
+ {file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"},
+]
+
+[[package]]
+name = "urllib3"
+version = "2.5.0"
+description = "HTTP library with thread-safe connection pooling, file post, and more."
+optional = false
+python-versions = ">=3.9"
+groups = ["dev"]
+files = [
+ {file = "urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc"},
+ {file = "urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760"},
+]
+
+[package.extras]
+brotli = ["brotli (>=1.0.9) ; platform_python_implementation == \"CPython\"", "brotlicffi (>=0.8.0) ; platform_python_implementation != \"CPython\""]
+h2 = ["h2 (>=4,<5)"]
+socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"]
+zstd = ["zstandard (>=0.18.0)"]
+
+[metadata]
+lock-version = "2.1"
+python-versions = ">=3.13"
+content-hash = "b4b34c1e3807b344acf1c696d37a064f420a68746f7a04abb0c71050a2e27701"

diff --git a/vendor/lxmfy/docs/pyproject.toml b/vendor/lxmfy/docs/pyproject.toml
new file mode 100644
index 00000000..50b71f34
--- /dev/null
+++ b/vendor/lxmfy/docs/pyproject.toml
@@ -0,0 +1,29 @@
+[project]
+name = "lxmfy-docs"
+version = "0.1.0"
+description = ""
+authors = [
+ {name = "Ivan",email = "ivan@quad4.io"}
+]
+readme = "README.md"
+requires-python = ">=3.13"
+dependencies = [
+]
+
+[tool.poetry]
+name = "lxmfy-docs"
+version = "0.1.0"
+description = ""
+authors = ["Ivan <ivan@quad4.io>"]
+readme = "README.md"
+package-mode = false
+
+
+[build-system]
+requires = ["poetry-core>=2.0.0,<3.0.0"]
+build-backend = "poetry.core.masonry.api"
+
+[tool.poetry.group.dev.dependencies]
+sphinx = "^8.2.3"
+furo = "^2025.7.19"
+

diff --git a/vendor/lxmfy/docs/source/_templates/header-buttons.html b/vendor/lxmfy/docs/source/_templates/header-buttons.html
new file mode 100644
index 00000000..0d653c69
--- /dev/null
+++ b/vendor/lxmfy/docs/source/_templates/header-buttons.html
@@ -0,0 +1,7 @@
+<div class="header-article-item">
+ {% if language == 'ru' %}
+ <a href="../{{ pagename }}.html" class="btn btn-sm" title="Switch to English" data-toggle="tooltip">English</a>
+ {% else %}
+ <a href="ru/{{ pagename }}.html" class="btn btn-sm" title="Переключиться на русский" data-toggle="tooltip">Русский</a>
+ {% endif %}
+</div>

diff --git a/vendor/lxmfy/docs/source/api-reference.rst b/vendor/lxmfy/docs/source/api-reference.rst
new file mode 100644
index 00000000..6e44d4f5
--- /dev/null
+++ b/vendor/lxmfy/docs/source/api-reference.rst
@@ -0,0 +1,492 @@
+Core Components
+================
+
+LXMFBot
+--------
+
+The main bot class that handles message routing, command processing, and bot lifecycle management.
+
+.. code-block:: python
+
+ from lxmfy import LXMFBot
+
+ bot = LXMFBot(
+ name="MyBot",
+ announce=600,
+ announce_immediately=True,
+ admins=set(),
+ hot_reloading=False,
+ rate_limit=5,
+ cooldown=60,
+ max_warnings=3,
+ warning_timeout=300,
+ command_prefix="/",
+ cogs_dir="cogs",
+ cogs_enabled=True,
+ permissions_enabled=False,
+ storage_type="json", # "json", "sqlite", or "memory"
+ storage_path="data",
+ first_message_enabled=True,
+ event_logging_enabled=True,
+ max_logged_events=1000,
+ event_middleware_enabled=True,
+ announce_enabled=True,
+ signature_verification_enabled=False,
+ require_message_signatures=False,
+ identity_pinning_enabled=False,
+ message_persistence_enabled=False,
+ dynamic_cogs_enabled=True,
+ external_cogs_enabled=True,
+ external_cogs_sandbox_enabled=True,
+ external_cogs_sandbox_type="auto",
+ external_cogs_timeout=30,
+ nlp_enabled=False,
+ nlp_threshold=0.5,
+ link_support_enabled=False
+ )
+
+Key Methods
+^^^^^^^^^^^
+
+- :code:`run(delay=10)`: Start the bot's main loop
+- :code:`send(destination, message, title="Reply", lxmf_fields=None, stamp_cost=None, opportunistic=None)`: Send a message to a destination, optionally with custom LXMF fields, stamp cost override, and opportunistic sending (tries direct, falls back to propagation immediately if configured).
+- :code:`send_with_attachment(destination, message, attachment, title="Reply", stamp_cost=None, opportunistic=None)`: Send a message with an attachment
+- :code:`command(name, description="No description provided", admin_only=False, threaded=False)`: Decorator for registering commands. Set :code:`threaded=True` to run the command's callback in a separate thread. Commands support type-hinted arguments for automatic conversion.
+- :code:`intent(name, examples)`: Decorator for registering NLP intent handlers.
+- :code:`nlp.export_model()`: Export trained NLP model data.
+- :code:`nlp.import_model(model_data)`: Import previously exported NLP model data.
+- :code:`request_link(destination_hash, callback=None, app_name="lxmf", *aspects)`: Request an RNS link to a destination. Allows custom :code:`app_name` and :code:`aspects` (defaults to "lxmf" and "delivery").
+- :code:`on_link(callback)`: Register a handler for incoming RNS links.
+- :code:`load_extension(name)`: Load a cog extension module by name (e.g., "cogs.utility").
+- :code:`reload_extension(name)`: Reload a cog extension module.
+- :code:`add_cog(cog_instance)`: Add a cog class instance to the bot.
+- :code:`remove_cog(cog_name)`: Remove a cog from the bot by its class name.
+- :code:`on_first_message()`: Decorator for handling first messages from users
+- :code:`on_message()`: Decorator for handling all messages (called before command processing)
+- :code:`validate()`: Run validation checks on the bot configuration
+
+Storage
+-------
+
+The framework provides three storage backends:
+
+JSONStorage
+^^^^^^^^^^^
+
+.. code-block:: python
+
+ from lxmfy import JSONStorage
+
+ storage = JSONStorage("data")
+
+SQLiteStorage
+^^^^^^^^^^^^^
+
+.. code-block:: python
+
+ from lxmfy import SQLiteStorage
+
+ storage = SQLiteStorage("data/bot.db")
+
+MemoryStorage
+^^^^^^^^^^^^^
+
+.. code-block:: python
+
+ from lxmfy.storage import MemoryStorage
+
+ storage = MemoryStorage() # Entirely in-memory
+
+Commands
+--------
+
+Command registration and handling:
+
+.. code-block:: python
+
+ @bot.command(name="hello", description="Says hello")
+ def hello(ctx):
+ ctx.reply(f"Hello {ctx.sender}!")
+
+Type-Hinted Arguments
+^^^^^^^^^^^^^^^^^^^^^
+
+Commands automatically parse and convert arguments based on type hints in the callback function.
+
+.. code-block:: python
+
+ @bot.command(name="add", description="Adds two numbers")
+ def add(ctx, a: int, b: int):
+ result = a + b
+ ctx.reply(f"The result is {result}")
+
+Help System
+-----------
+
+The framework includes an interactive help generator that provides beautiful, categorized help menus based on Cog and Command metadata.
+
+.. code-block:: python
+
+ # The help command is automatically registered.
+ # Users can use '/help' or '/help <command>'
+
+Threaded Commands
+^^^^^^^^^^^^^^^^^
+
+For long-running or blocking operations that do not interact with the Reticulum Network Stack directly, you can run commands in a separate thread to keep the bot responsive.
+
+.. code-block:: python
+
+ import time
+
+ @bot.command(name="long_task", description="Performs a long-running task in a separate thread", threaded=True)
+ def long_task_command(ctx):
+ ctx.reply("Starting a long task... please wait.")
+ time.sleep(10) # This runs in a separate thread
+ ctx.reply("Long task completed!")
+
+**Important:** Functions marked as :code:`threaded=True` **must not** directly interact with the Reticulum Network Stack (RNS) or any components that rely on :code:`lxmfy.transport.py`, as these are generally not thread-safe. Use :code:`ctx.reply()` for sending messages back to the user from within a threaded command.
+
+Events
+------
+
+Event system for handling various bot events:
+
+.. code-block:: python
+
+ @bot.events.on("message_received", EventPriority.HIGHEST)
+ def handle_message(event):
+ # Handle message event
+ pass
+
+Testing
+-------
+
+Project tests include reliability and stress scenarios in the repository test suite.
+Use the repository's test runner to execute them.
+
+Advanced Reliability Suite
+^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+The framework includes an extensive suite of automated tests for harsh environments:
+
+- **Manifold Testing**: Validates the mathematical topology of NLP intent vector space.
+- **Chaos Engineering**: Simulates bit-rot, SD card failure, and storage corruption.
+- **Temporal Drift**: Verifies resilience against system clock jumps (±1 year).
+- **Leak Detection**: Long-term tracking of memory, file descriptors, and threads.
+
+Permissions
+-----------
+
+Permission system for controlling access to bot features:
+
+.. code-block:: python
+
+ from lxmfy import DefaultPerms
+
+ @bot.command(name="admin", description="Admin command", admin_only=True)
+ def admin_command(ctx):
+ if ctx.is_admin:
+ ctx.reply("Admin command executed")
+
+Middleware
+----------
+
+Middleware system for processing messages and events:
+
+.. code-block:: python
+
+ @bot.middleware.register(MiddlewareType.PRE_COMMAND)
+ def pre_command_middleware(ctx):
+ # Process before command execution
+ pass
+
+Attachments
+-----------
+
+Support for sending files, images, and audio:
+
+.. code-block:: python
+
+ from lxmfy import Attachment, AttachmentType
+
+ attachment = Attachment(
+ type=AttachmentType.IMAGE,
+ name="image.jpg",
+ data=image_data,
+ format="jpg"
+ )
+ bot.send_with_attachment(destination, "Here's an image", attachment)
+
+Icon Appearance (LXMF Field)
+-----------------------------
+
+You can set a custom icon for your bot that compliant LXMF clients can display. This uses the :code:`LXMF.FIELD_ICON_APPEARANCE`.
+
+.. code-block:: python
+
+ from lxmfy import IconAppearance, pack_icon_appearance_field
+ import LXMF # Required for LXMF.FIELD_ICON_APPEARANCE
+
+ # Define the icon appearance
+ icon_data = IconAppearance(
+ icon_name="smart_toy", # Name from Material Symbols
+ fg_color=b'\xFF\xFF\xFF', # White foreground (3 bytes)
+ bg_color=b'\x4A\x90\xE2' # Blue background (3 bytes)
+ )
+
+ # Pack it into the LXMF field format
+ icon_lxmf_field = pack_icon_appearance_field(icon_data)
+
+ # Send a message with this icon
+ bot.send(
+ destination_hash_str,
+ "Hello from your friendly bot!",
+ title="Bot Message",
+ lxmf_fields=icon_lxmf_field
+ )
+
+ # You can also combine it with other fields, like attachments:
+ # attachment_field = pack_attachment(some_attachment)
+ # combined_fields = {**icon_lxmf_field, **attachment_field}
+ # bot.send(destination, "Message with icon and attachment", lxmf_fields=combined_fields)
+
+Scheduler
+---------
+
+Task scheduling system:
+
+.. code-block:: python
+
+ @bot.scheduler.schedule(name="daily_task", cron_expr="0 0 * * *")
+ def daily_task():
+ # Run daily at midnight
+ pass
+
+Signatures
+----------
+
+LXMFy provides configuration options for LXMF's built-in cryptographic message signing and verification:
+
+.. code-block:: python
+
+ from lxmfy import LXMFBot
+
+ bot = LXMFBot(
+ name="SecureBot",
+ signature_verification_enabled=True, # Enable signature checks
+ require_message_signatures=False # Set to True to reject unsigned messages
+ )
+
+**Important:** LXMF automatically handles all cryptographic signing and verification using RNS identities. LXMFy's :code:`SignatureManager` is a configuration layer that:
+
+- Controls whether to enforce signature verification
+- Determines policy for unsigned messages (accept or reject)
+- Integrates with the permission system (e.g., bypass verification for trusted users)
+
+The actual cryptographic operations are performed by LXMF/RNS, not by LXMFy.
+
+Identity Pinning
+^^^^^^^^^^^^^^^^
+
+LXMFy supports optional identity pinning to prevent impersonation if an identity is rotated or compromised. When enabled, the bot "pins" an LXMF address to its first-seen public key.
+
+.. code-block:: python
+
+ bot = LXMFBot(
+ identity_pinning_enabled=True
+ )
+
+SignatureManager Methods
+^^^^^^^^^^^^^^^^^^^^^^^^
+
+The :code:`SignatureManager` is available as :code:`bot.signature_manager` when :code:`signature_verification_enabled=True`:
+
+- :code:`should_verify_message(sender)`: Determine if a message from the given sender should be verified
+- :code:`handle_unsigned_message(sender, message_hash)`: Handle messages that lack valid signatures based on policy
+
+How LXMF Signatures Work
+^^^^^^^^^^^^^^^^^^^^^^^^^
+
+LXMF automatically signs all outgoing messages using the sender's RNS identity during the :code:`pack()` operation. When messages are received, LXMF validates signatures and provides:
+
+- :code:`message.signature_validated`: Boolean indicating if the signature is valid
+- :code:`message.unverified_reason`: Reason code if validation failed (e.g., :code:`SIGNATURE_INVALID`, :code:`SOURCE_UNKNOWN`)
+
+LXMFy uses these built-in LXMF properties to enforce your bot's signature policy.
+
+Message Delivery
+----------------
+
+LXMFy provides advanced message delivery features including propagation nodes and automatic retries:
+
+Propagation Nodes
+^^^^^^^^^^^^^^^^^
+
+Send messages through specific propagation nodes for improved reliability on the Reticulum network:
+
+.. code-block:: python
+
+ # Configure the propagation node once at config/runtime level
+ bot.set_propagation_node("<propagation_node_hash>")
+
+ # Send using configured delivery behavior
+ bot.send(
+ destination_hash,
+ "Message content"
+ )
+
+ # The propagation node hash should be a valid LXMF propagation node
+ # on the Reticulum network
+
+Automatic Retries
+^^^^^^^^^^^^^^^^^
+
+Configure automatic retry attempts for failed direct deliveries:
+
+.. code-block:: python
+
+ bot = LXMFBot(
+ name="ReliableBot",
+ direct_delivery_retries=5, # Retry direct delivery up to 5 times
+ propagation_fallback_enabled=True
+ )
+
+ bot.send(destination_hash, "Important message")
+
+ # Default direct_delivery_retries is 3
+ # Retry logic automatically handles delivery callbacks
+
+The retry system tracks delivery attempts per destination and automatically retries failed deliveries. Successful deliveries reset the retry counter for that destination.
+
+Message Persistence
+^^^^^^^^^^^^^^^^^^^
+
+Outgoing messages can be persisted to disk to ensure they are delivered even after a bot restart.
+
+.. code-block:: python
+
+ bot = LXMFBot(
+ message_persistence_enabled=True
+ )
+
+Message Handlers
+----------------
+
+LXMFy provides decorators for handling different types of incoming messages:
+
+First Message Handler
+^^^^^^^^^^^^^^^^^^^^^
+
+Handle the first message from each user:
+
+.. code-block:: python
+
+ @bot.on_first_message()
+ def welcome_user(sender, message):
+ content = message.content.decode("utf-8")
+ bot.send(sender, f"Welcome! You said: {content}")
+ return True # Return True to stop further processing
+
+General Message Handler
+^^^^^^^^^^^^^^^^^^^^^^^
+
+Handle all incoming messages before command processing:
+
+.. code-block:: python
+
+ @bot.on_message()
+ def handle_all_messages(sender, message):
+ content = message.content.decode("utf-8").strip()
+
+ # Custom logic here
+ if content.startswith("echo:"):
+ bot.send(sender, content[5:])
+ return True # Stop further processing
+
+ return False # Continue to command processing
+
+Message handlers are called in this order:
+1. First message handler (if this is the first message from this sender)
+2. General message handlers (registered with :code:`on_message()`)
+3. Command processing (if message starts with command prefix)
+
+Templates
+=========
+
+The framework includes several ready-to-use bot templates:
+
+EchoBot
+-------
+
+Simple echo bot that repeats messages:
+
+.. code-block:: python
+
+ from lxmfy.templates import EchoBot
+
+ bot = EchoBot()
+ bot.run()
+
+NoteBot
+-------
+
+Note-taking bot with JSON storage:
+
+.. code-block:: python
+
+ from lxmfy.templates import NoteBot
+
+ bot = NoteBot()
+ bot.run()
+
+ReminderBot
+-----------
+
+Reminder bot with SQLite storage:
+
+.. code-block:: python
+
+ from lxmfy.templates import ReminderBot
+
+ bot = ReminderBot()
+ bot.run()
+
+CLI Tools
+=========
+
+The framework provides command-line tools for bot management:
+
+.. code-block:: bash
+
+ # Create a new bot
+ lxmfy create mybot
+
+ # Create a bot from template
+ lxmfy create --template echo mybot
+
+ # Run a template bot
+ lxmfy run echo
+
+ # Test signature verification with a message
+ lxmfy signatures test
+
+ # Enable signature verification
+ lxmfy signatures enable
+
+ # Disable signature verification
+ lxmfy signatures disable
+
+Error Handling
+==============
+
+The framework provides comprehensive error handling:
+
+.. code-block:: python
+
+ try:
+ bot.run()
+ except KeyboardInterrupt:
+ bot.cleanup()
+ except Exception as e:
+ logger.error(f"Error running bot: {str(e)}")

diff --git a/vendor/lxmfy/docs/source/conf.py b/vendor/lxmfy/docs/source/conf.py
new file mode 100644
index 00000000..d4dfc3ff
--- /dev/null
+++ b/vendor/lxmfy/docs/source/conf.py
@@ -0,0 +1,42 @@
+# Configuration file for the Sphinx documentation builder.
+#
+# For the full list of built-in configuration values, see the documentation:
+# https://www.sphinx-doc.org/en/master/usage/configuration.html
+
+# -- Project information -----------------------------------------------------
+# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information
+
+project = "LXMFy"
+copyright = "2025, Ivan"
+author = "Ivan"
+
+# -- General configuration ---------------------------------------------------
+# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration
+
+extensions = [
+ "sphinx.ext.autodoc",
+ "sphinx.ext.viewcode",
+ "sphinx.ext.napoleon",
+]
+
+# -- Internationalization ---------------------------------------------------
+# https://www.sphinx-doc.org/en/master/usage/configuration.html#internationalization
+
+locale_dirs = ["../locales/"]
+gettext_compact = False
+
+templates_path = ["_templates"]
+exclude_patterns = []
+
+
+# -- Options for HTML output -------------------------------------------------
+# https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output
+
+html_theme = "furo"
+html_static_path = ["_static"]
+
+# Furo theme options
+html_theme_options = {
+ "sidebar_hide_name": False,
+ "navigation_with_keys": True,
+}

diff --git a/vendor/lxmfy/docs/source/creating-bots.rst b/vendor/lxmfy/docs/source/creating-bots.rst
new file mode 100644
index 00000000..2ed137e2
--- /dev/null
+++ b/vendor/lxmfy/docs/source/creating-bots.rst
@@ -0,0 +1,582 @@
+Creating Bots
+=============
+
+Basic Structure
+---------------
+
+A minimal LXMFy bot involves:
+
+1. Importing :code:`LXMFBot`.
+2. Instantiating :code:`LXMFBot` with desired configuration.
+3. Defining commands or event handlers.
+4. Running the bot using :code:`bot.run()`.
+
+.. code-block:: python
+
+ from lxmfy import LXMFBot
+
+ # 1. Instantiate the bot
+ bot = LXMFBot(
+ name="SimpleBot",
+ command_prefix="!",
+ storage_path="simple_data"
+ )
+
+ # 2. Define commands
+ @bot.command(name="ping", description="Responds with pong")
+ def ping_command(ctx):
+ # ctx is a context object containing message info
+ # ctx.sender: Sender's LXMF hash
+ # ctx.content: Full message content
+ # ctx.args: List of arguments after the command
+ # ctx.reply(message): Function to send a reply
+ # (can also take keyword arguments like title="My Title", lxmf_fields=some_fields)
+ ctx.reply("Pong!")
+
+ # For long-running tasks, you can use threaded commands:
+ # import time
+ # @bot.command(name="long_op", description="Performs a long operation in a separate thread", threaded=True)
+ # def long_op_command(ctx):
+ # ctx.reply("Starting long operation...")
+ # time.sleep(10) # Simulate a long-running operation
+ # ctx.reply("Long operation complete!")
+ # Important: Threaded commands should not directly interact with RNS or lxmfy.transport.py.
+
+ @bot.command(name="greet", description="Greets the user")
+ def greet_command(ctx):
+ if ctx.args:
+ name = " ".join(ctx.args)
+ ctx.reply(f"Hello, {name}!")
+ else:
+ ctx.reply("Hello there! Tell me your name: !greet <your_name>")
+
+ # 3. Run the bot
+ if __name__ == "__main__":
+ print(f"Starting bot: {bot.config.name}")
+ print(f"Bot LXMF Address: {bot.local.hash}")
+ bot.run()
+
+Using Templates
+---------------
+
+LXMFy provides several templates for common bot types. You can use the CLI to generate a bot file based on a template.
+
+.. code-block:: bash
+
+ # Create an echo bot
+ lxmfy create --template echo my_echo_bot
+
+ # Create a reminder bot (uses SQLite storage)
+ lxmfy create --template reminder my_reminder_bot
+
+ # Create a note-taking bot (uses JSON storage)
+ lxmfy create --template note my_note_bot
+
+ # Create a cog test bot (tests cog loading features)
+ lxmfy create --template cogtest my_cog_test_bot
+
+Running these commands creates a Python file (e.g., :code:`my_echo_bot.py`) that imports and runs the chosen template. You can then modify the generated file or the template code itself (:code:`lxmfy/templates/...`).
+
+**Example generated file (:code:`my_cog_test_bot.py`):**
+
+.. code-block:: python
+
+ from lxmfy.templates import CogTestBot
+
+ if __name__ == "__main__":
+ bot = CogTestBot() # Creates an instance of the CogTestBot template
+ # You can optionally override the default name:
+ # bot.bot.name = "My Cog Test Bot"
+ bot.run()
+
+Bot Configuration
+-----------------
+
+When creating an :code:`LXMFBot` instance, you can pass various keyword arguments to configure its behavior. See the :code:`BotConfig` section in the `API Reference <api-reference.html>`_ or the `Quick Start Guide <quick-start.html>`_ for a list of common options.
+
+.. code-block:: python
+
+ from lxmfy import LXMFBot
+
+ bot = LXMFBot(
+ name="ConfiguredBot",
+ announce=3600, # Announce every hour
+ admins={"your_admin_hash_here"}, # Set admin user(s)
+ command_prefix="$", # Use '$' as prefix
+ storage_type="sqlite", # Use SQLite database
+ storage_path="data/my_bot_data.db", # Specify DB file path
+ rate_limit=10, # Allow 10 messages / minute
+ cooldown=30, # Cooldown of 30 seconds
+ permissions_enabled=True # Enable role-based permissions
+ )
+
+ if __name__ == "__main__":
+ # You can also modify config after instantiation
+ # Note: some settings are best set during init
+ bot.config.max_warnings = 5
+ bot.spam_protection.config.max_warnings = 5 # Update spam protector too
+
+ bot.run()
+
+Setting a Bot Icon (LXMF Field)
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+You can give your bot a custom icon that appears in compatible LXMF clients. This uses the :code:`LXMF.FIELD_ICON_APPEARANCE` and can be set when sending messages.
+
+First, ensure you have the necessary imports:
+
+.. code-block:: python
+
+ from lxmfy import IconAppearance, pack_icon_appearance_field
+
+Then, you can define and use the icon:
+
+.. code-block:: python
+
+ # In your bot class or setup
+ icon_data = IconAppearance(
+ icon_name="robot_2", # Choose from Material Symbols
+ fg_color=b'\x00\xFF\x00', # Green
+ bg_color=b'\x33\x33\x33' # Dark Grey
+ )
+ self.bot_icon_field = pack_icon_appearance_field(icon_data)
+
+ # When sending a message or replying:
+ ctx.reply("Message from your bot!", lxmf_fields=self.bot_icon_field)
+ # or
+ # bot.send(destination, "Another message", lxmf_fields=self.bot_icon_field)
+
+This :code:`self.bot_icon_field` can be pre-calculated and reused for all messages sent by the bot.
+
+Using Cogs (Extensions)
+-----------------------
+
+Cogs allow you to organize your commands and event listeners into separate files (modules), keeping your main bot file cleaner.
+
+1. **Create a :code:`cogs` directory** (or whatever you set :code:`cogs_dir` to in :code:`BotConfig`).
+2. **Create Python files** inside the :code:`cogs` directory (e.g., :code:`utility.py`).
+3. **Define a class** that inherits from :code:`lxmfy.Cog` (optional but good practice) or is just a standard class.
+4. **Define commands** as methods within the class using the :code:`@Command` decorator.
+5. **Create a :code:`setup(bot)` function** in the cog file, which LXMFy will call to register the cog.
+
+**Example (:code:`cogs/utility.py`):**
+
+.. code-block:: python
+
+ from lxmfy import Command
+ from lxmfy.commands import Cog # Import Cog if inheriting
+ import time
+
+ class UtilityCog: # Or class UtilityCog(Cog):
+ def __init__(self, bot):
+ self.bot = bot
+ self.start_time = time.time()
+
+ @Command(name="uptime", description="Shows bot uptime")
+ # Note: Methods in cogs often take 'self' and 'ctx'
+ def uptime_command(self, ctx):
+ uptime_seconds = time.time() - self.start_time
+ ctx.reply(f"Bot has been running for {uptime_seconds:.2f} seconds.")
+
+ @Command(name="info", description="Shows bot info")
+ def info_command(self, ctx):
+ info = (
+ f"Bot Name: {self.bot.config.name}\n"
+ f"Owner(s): {', '.join(self.bot.config.admins) or 'None'}\n"
+ f"Prefix: {self.bot.config.command_prefix}"
+ )
+ ctx.reply(info)
+
+ @Command(name="threaded_cog_task", description="Performs a long task in a cog thread", threaded=True)
+ def threaded_cog_task(self, ctx):
+ ctx.reply("Starting a long cog task... this will run in a separate thread.")
+ time.sleep(7) # Simulate a long-running operation
+ ctx.reply("Long cog task completed!")
+
+ # This function is required for the cog to be loaded
+ def setup(bot):
+ cog_instance = UtilityCog(bot)
+ bot.add_cog(cog_instance) # Register the cog instance with the bot
+
+**Main Bot File (:code:`my_bot.py`):**
+
+.. code-block:: python
+
+ from lxmfy import LXMFBot
+
+ bot = LXMFBot(
+ name="CogBot",
+ cogs_enabled=True, # Make sure cogs are enabled (default)
+ cogs_dir="cogs" # Point to the directory
+ )
+
+ if __name__ == "__main__":
+ # Cogs are loaded automatically during LXMFBot initialization
+ # if cogs_enabled is True.
+ bot.run()
+
+When the bot starts, it will automatically find :code:`utility.py`, call its :code:`setup` function, which creates an instance of :code:`UtilityCog` and registers it using :code:`bot.add_cog()`. The commands defined in the cog (:code:`uptime`, :code:`info`) will then be available.
+
+External Script Cogs (Multi-Language Support)
+---------------------------------------------
+
+You can also write bot extensions in languages other than Python (e.g., Bash, Ruby, Perl, Go, C) using External Script Cogs.
+
+1. **Create an executable script** in your :code:`cogs` directory.
+2. **Add a shebang** at the top of the script (e.g., :code:`#!/bin/bash`).
+3. **Ensure the script is executable** (:code:`chmod +x your_script`).
+
+When the bot starts, it will automatically register any executable file in the :code:`cogs` directory (that doesn't end in :code:`.py`) as a bot command.
+
+**Argument Protocol:**
+
+- :code:`$1`: Sender's LXMF hash.
+- :code:`$2`: Full message content.
+- :code:`$3`, :code:`$4`, ...: Individual command arguments.
+
+**Environment Variables:**
+
+- :code:`LXMFY_SENDER`: The sender's identity hash.
+- :code:`LXMFY_CONTENT`: The full message content.
+- :code:`LXMFY_HAS_ADMIN`: :code:`true` or :code:`false` depending on the sender's admin status.
+
+**Example Bash Cog (:code:`cogs/greet.sh`):**
+
+.. code-block:: bash
+
+ #!/bin/bash
+ echo "Hello from Bash! You sent: $2"
+
+When a user sends :code:`/greet hello`, the bot will execute this script and reply with its stdout: :code:`Hello from Bash! You sent: /greet hello`.
+
+Sovereign NLP (Local Intent Classification)
+-------------------------------------------
+
+LXMFy includes a built-in, lightweight NLP engine for intent classification. This allows your bot to understand the "intent" of a message even if it doesn't match a command exactly.
+
+1. **Enable NLP** in your bot configuration: :code:`nlp_enabled=True`.
+2. **Define intents** using the :code:`@bot.intent` decorator.
+
+.. code-block:: python
+
+ @bot.intent("help", examples=["how do I use this?", "show me commands", "help me please"])
+ def help_intent(msg):
+ msg.reply("I can help! Try typing /help to see a list of commands.")
+
+The NLP engine uses mathematical vector similarity (TF-IDF and Cosine Similarity) to match incoming text against your example phrases. This processing happens entirely locally on your machine, ensuring full privacy.
+
+**Persistence and Extensibility:**
+
+For larger bots, you can export and import the trained intent model to avoid retraining on every startup:
+
+.. code-block:: python
+
+ # Export the model
+ model_data = bot.nlp.export_model()
+ # Save model_data to a file or database
+
+ # Later, import it back
+ bot.nlp.import_model(model_data)
+
+RNS Link Support
+----------------
+
+Bots can now establish and respond to direct RNS Links. This is useful for stateful, streaming, or high-bandwidth communication that goes beyond simple message packets.
+
+1. **Enable Link Support** in configuration: :code:`link_support_enabled=True`.
+2. **Request a link**: :code:`bot.request_link(destination_hash)`. You can also specify a custom app name and aspects: :code:`bot.request_link(dest, callback, "my_app", "aspect1")`.
+3. **Handle incoming links**: Register a callback with :code:`bot.on_link(handler)`.
+
+.. code-block:: python
+
+ def handle_link(link):
+ print(f"Link established with {RNS.hexrep(link.destination.hash)}")
+ # You can now use the link for direct RNS communication
+
+ bot.on_link(handle_link)
+
+**Safety & Sandboxing:**
+
+- **Timeouts:** External cogs have a default timeout (30s) to prevent hanging. This is configurable via :code:`external_cogs_timeout`.
+- **Threading:** All external cogs run in separate threads and do not block the bot.
+- **Sandboxing (Linux only):** If :code:`bubblewrap` (:code:`bwrap`) or :code:`firejail` is installed, the bot can automatically run scripts in a restricted, read-only sandbox. This is enabled by default via :code:`external_cogs_sandbox_enabled`.
+
+Handling Messages
+-----------------
+
+LXMFy provides several ways to handle incoming messages at different stages of processing.
+
+First Message Handler
+^^^^^^^^^^^^^^^^^^^^^
+
+Handle the first message from each new user (useful for welcome messages):
+
+.. code-block:: python
+
+ from lxmfy import LXMFBot
+
+ bot = LXMFBot(
+ name="WelcomeBot",
+ first_message_enabled=True # Must be True (default)
+ )
+
+ @bot.on_first_message()
+ def welcome_new_user(sender, message):
+ content = message.content.decode("utf-8")
+ bot.send(
+ sender,
+ f"Welcome to the bot! You said: {content}\n\n"
+ "Type /help to see available commands."
+ )
+ return True # Return True to stop further processing of this message
+
+ if __name__ == "__main__":
+ bot.run()
+
+General Message Handler
+^^^^^^^^^^^^^^^^^^^^^^^
+
+Handle all incoming messages before command processing:
+
+.. code-block:: python
+
+ from lxmfy import LXMFBot
+
+ bot = LXMFBot(name="EchoBot")
+
+ @bot.on_message()
+ def echo_non_commands(sender, message):
+ content = message.content.decode("utf-8").strip()
+
+ # Check if this is a command - if so, let command handler deal with it
+ if content.startswith(bot.config.command_prefix):
+ command_name = content.split()[0][len(bot.config.command_prefix):]
+ if command_name in bot.commands:
+ return False # Let command handler process it
+
+ # Not a command, echo it back
+ bot.send(sender, f"You said: {content}")
+ return False # Return False to continue processing (though no commands will match)
+
+ @bot.command(name="hello", description="Say hello")
+ def hello_command(ctx):
+ ctx.reply("Hello! This is a command response.")
+
+ if __name__ == "__main__":
+ bot.run()
+
+Message Handler Processing Order:
+
+1. **First Message Handler** (if :code:`first_message_enabled=True` and this is first message from sender)
+2. **General Message Handlers** (registered with :code:`@bot.on_message()`)
+3. **Command Processing** (if message matches a registered command)
+
+Handlers can return :code:`True` to stop further processing or :code:`False` to continue to the next stage.
+
+Handling Events
+---------------
+
+You can register handlers for various bot events using the :code:`@bot.events.on()` decorator.
+
+.. code-block:: python
+
+ from lxmfy import LXMFBot
+ from lxmfy.events import EventPriority # Optional for priority
+
+ bot = LXMFBot(name="EventBot")
+
+ @bot.events.on("message_received")
+ def log_message(event):
+ # Event object contains details
+ sender = event.data.get("sender")
+ message_content = event.data.get("message").content.decode('utf-8', errors='ignore')
+ print(f"Received message from {sender}: {message_content}")
+
+ # You can cancel event processing (e.g., stop message handling)
+ # if sender == "some_blocked_hash":
+ # event.cancel()
+
+ @bot.events.on("command_executed", priority=EventPriority.LOW)
+ def log_command(event):
+ # Example: event.data might contain {'command_name': 'ping', 'sender': '...', ...}
+ command_name = event.data.get('command_name', 'unknown')
+ sender = event.data.get('sender', 'unknown')
+ print(f"Command '{command_name}' executed by {sender}")
+
+ # You can define custom events too
+ @bot.command(name="special")
+ def special_command(ctx):
+ ctx.reply("Doing something special!")
+ # Dispatch a custom event
+ bot.events.dispatch(Event("special_action_taken", data={"user": ctx.sender}))
+
+ @bot.events.on("special_action_taken")
+ def handle_special(event):
+ user = event.data.get("user")
+ print(f"Special action was taken by user: {user}")
+
+
+ if __name__ == "__main__":
+ bot.run()
+
+See :code:`lxmfy/events.py` for more details on the :code:`Event` structure and priorities.
+
+Storage
+-------
+
+LXMFy provides JSON, SQLite, and In-Memory storage backends.
+
+* **JSON:** Simple, human-readable. Good for small datasets. Configure with :code:`storage_type="json"` and :code:`storage_path="your_data_dir"`.
+* **SQLite:** More efficient for larger datasets or frequent writes. Configure with :code:`storage_type="sqlite"` and :code:`storage_path="your_db_file.db"`.
+* **Memory:** Entirely in-RAM storage. State is lost on shutdown. Configure with :code:`storage_type="memory"`.
+
+You can access the storage interface via :code:`bot.storage`:
+
+.. code-block:: python
+
+ # Save data
+ bot.storage.set("user_prefs:" + ctx.sender, {"theme": "dark"})
+
+ # Get data (with a default value)
+ prefs = bot.storage.get("user_prefs:" + ctx.sender, {})
+ theme = prefs.get("theme", "light")
+
+ # Check if data exists
+ if bot.storage.exists("some_key"):
+ print("Key exists!")
+
+ # Delete data
+ bot.storage.delete("old_data_key")
+
+ # Scan for keys with a prefix (useful for listing user data)
+ user_keys = bot.storage.scan("user_prefs:")
+ for key in user_keys:
+ user_data = bot.storage.get(key)
+ print(f"Data for {key}: {user_data}")
+
+See :code:`lxmfy/storage.py` and the API reference for more details.
+
+Permissions
+-----------
+
+LXMFy includes an optional role-based permission system. Enable it with :code:`permissions_enabled=True` during :code:`LXMFBot` initialization.
+
+* **Roles:** Define roles with specific permissions (e.g., :code:`DefaultPerms.MANAGE_USERS`).
+* **Permissions:** Granular flags defined in :code:`DefaultPerms` (e.g., :code:`USE_COMMANDS`, :code:`BYPASS_SPAM`).
+* **Assignment:** Assign roles to user hashes.
+
+See :code:`lxmfy/permissions.py`, the API reference, and potentially example cogs (if any are created) for usage details.
+
+Signature Verification
+----------------------
+
+LXMFy provides configuration for LXMF's built-in cryptographic message signing and verification. All LXMF messages are automatically signed by the LXMF/RNS stack - LXMFy simply allows you to enforce signature verification policies.
+
+**Configuration:**
+
+Enable signature verification in your bot configuration:
+
+.. code-block:: python
+
+ bot = LXMFBot(
+ name="SecureBot",
+ signature_verification_enabled=True, # Enable signature checking
+ require_message_signatures=False # Set to True to reject unsigned messages
+ )
+
+**How It Works:**
+
+LXMF automatically handles all cryptographic operations:
+
+1. **Outgoing Messages:** LXMF automatically signs all messages using the sender's RNS identity during message packing.
+
+2. **Incoming Messages:** LXMF automatically validates signatures using the sender's RNS identity and provides validation results.
+
+3. **LXMFy's Role:** LXMFy checks LXMF's validation results and enforces your policy:
+
+ - If :code:`signature_verification_enabled=False`: All messages are accepted (default)
+ - If :code:`signature_verification_enabled=True` and :code:`require_message_signatures=False`: Messages are accepted but unsigned/invalid signatures are logged
+ - If :code:`signature_verification_enabled=True` and :code:`require_message_signatures=True`: Unsigned or invalid messages are rejected
+
+4. **Permission Integration:** Users with :code:`BYPASS_SPAM` permission can bypass signature verification requirements.
+
+**CLI Management:**
+
+You can manage signature verification settings using the CLI:
+
+.. code-block:: bash
+
+ # Test signature verification
+ lxmfy signatures test
+
+ # Enable signature verification
+ lxmfy signatures enable
+
+ # Disable signature verification
+ lxmfy signatures disable
+
+**Technical Details:**
+
+LXMF uses Ed25519 signatures provided by the RNS cryptography system. Every LXMF message includes the sender's signature, which is validated against their known RNS identity. LXMFy simply reads LXMF's :code:`message.signature_validated` property and :code:`message.unverified_reason` to enforce your bot's security policy.
+
+Advanced Message Delivery
+--------------------------
+
+LXMFy supports advanced message delivery options for improved reliability.
+
+Using Propagation Nodes
+^^^^^^^^^^^^^^^^^^^^^^^^
+
+Send messages through specific LXMF propagation nodes:
+
+.. code-block:: python
+
+ from lxmfy import LXMFBot
+
+ bot = LXMFBot(name="PropagationBot")
+
+ @bot.command(name="send", description="Send via propagation node")
+ def send_command(ctx):
+ # Set a specific propagation node once (config-level)
+ bot.set_propagation_node("<propagation_node_hash_here>")
+
+ # Send using configured delivery strategy
+ bot.send(
+ ctx.sender,
+ "This message will use direct delivery with propagation fallback as configured"
+ )
+
+Propagation nodes are useful when direct delivery is not possible or when you want to ensure message delivery through the Reticulum mesh network.
+
+Configuring Retries
+^^^^^^^^^^^^^^^^^^^
+
+Configure automatic retry attempts for failed message deliveries via bot config:
+
+.. code-block:: python
+
+ from lxmfy import LXMFBot
+
+ bot = LXMFBot(name="ReliableBot")
+
+ bot = LXMFBot(
+ name="ReliableBot",
+ direct_delivery_retries=5, # Retry direct delivery up to 5 times
+ propagation_fallback_enabled=True
+ )
+
+ @bot.command(name="important", description="Send important message with retries")
+ def important_command(ctx):
+ bot.send(ctx.sender, "This is an important message")
+
+ @bot.command(name="normal", description="Send with default retries")
+ def normal_command(ctx):
+ # Default direct_delivery_retries is 3
+ bot.send(ctx.sender, "This message uses default retry settings")
+
+The retry system:
+
+- Automatically tracks delivery attempts per destination
+- Retries failed direct deliveries up to :code:`direct_delivery_retries`
+- Resets the retry counter on successful delivery
+- Logs retry attempts and failures for debugging

diff --git a/vendor/lxmfy/docs/source/index.rst b/vendor/lxmfy/docs/source/index.rst
new file mode 100644
index 00000000..be5133d8
--- /dev/null
+++ b/vendor/lxmfy/docs/source/index.rst
@@ -0,0 +1,26 @@
+lxmfy documentation
+========================
+
+A framework for creating `LXMF <https://git.quad4.io/LXMFy/LXMFy>`_ bots on the `Reticulum Network <https://reticulum.network/>`_.
+
+Download
+--------
+
+Get the latest version of LXMFy docs (PDF, EPUB, HTML and Text) from our `Gitea repository <https://git.quad4.io/LXMFy/LXMFy>`_.
+
+Languages
+---------
+
+This documentation is available in the following languages:
+
+* `English <index.html>`_
+* `Русский <ru/index.html>`_
+
+.. toctree::
+ :maxdepth: 2
+ :caption: Contents:
+
+ quick-start
+ creating-bots
+ api-reference
+

diff --git a/vendor/lxmfy/docs/source/quick-start.rst b/vendor/lxmfy/docs/source/quick-start.rst
new file mode 100644
index 00000000..429852f4
--- /dev/null
+++ b/vendor/lxmfy/docs/source/quick-start.rst
@@ -0,0 +1,127 @@
+Quick Start
+===========
+
+Prerequisites
+-------------
+
+* Python 3.11+
+* Reticulum Network Stack (:code:`pip install rns`)
+* LXMFy (:code:`pip install lxmfy` or install from source)
+
+Creating Your First Bot (Using the CLI)
+----------------------------------------
+
+The easiest way to start is using the LXMFy command-line tool.
+
+1. **Open your terminal** in the directory where you want to create your bot project.
+2. **Run the create command:**
+
+ .. code-block:: bash
+
+ lxmfy create my_first_bot
+
+ This command will generate the following files:
+ * :code:`my_first_bot.py`: Your main bot file, configured with sensible defaults.
+ * :code:`cogs/`: A directory for bot extensions (cogs).
+ * :code:`cogs/__init__.py`: Makes the :code:`cogs` directory a Python package.
+ * :code:`cogs/basic.py`: An example cog with simple "hello" and "about" commands.
+ * :code:`data/`: A directory where the bot will store its data (using JSON by default).
+ * :code:`config/`: A directory where the bot stores its identity and announce status.
+
+3. **Review the :code:`my_first_bot.py` file:**
+
+ .. code-block:: python
+
+ from lxmfy import LXMFBot
+
+ bot = LXMFBot(
+ name="my_first_bot", # Bot name used in announces/identity
+ announce=600, # Announce interval in seconds (10 minutes)
+ announce_immediately=True, # Announce on first run?
+ admins=set(), # Set of admin LXMF address hashes
+ hot_reloading=False, # Enable/disable hot reloading of cogs
+ rate_limit=5, # Max messages per minute per user
+ cooldown=60, # Cooldown period in seconds for rate limit
+ max_warnings=3, # Warnings before ban for spam
+ warning_timeout=300, # Time (seconds) before warnings reset
+ command_prefix="/", # Prefix for commands (e.g., /hello)
+ cogs_dir="cogs", # Directory to load cogs from
+ cogs_enabled=True, # Enable/disable loading cogs
+ permissions_enabled=False, # Enable/disable the role-based permission system
+ storage_type="json", # Storage backend ("json", "sqlite", or "memory")
+ storage_path="data", # Path for storage files/database
+ first_message_enabled=True, # Enable special handling for first messages
+ event_logging_enabled=True, # Log events to storage?
+ max_logged_events=1000, # Max events to keep in log
+ event_middleware_enabled=True, # Enable event middleware?
+ announce_enabled=True, # Enable/disable network announces
+ signature_verification_enabled=False, # Enable/disable cryptographic signature verification
+ require_message_signatures=False # Require all messages to be signed
+ )
+
+ # To add an admin, find your LXMF address hash and add it here:
+ # bot.config.admins.add("your_lxmf_hash_here")
+ # bot.admins = bot.config.admins # Ensure the running instance knows
+
+ # Example of preparing an LXMF icon field (optional)
+ # from lxmfy import IconAppearance, pack_icon_appearance_field
+ # try:
+ # icon_data = IconAppearance(icon_name="emoji_objects", fg_color=b'\xFF\xA5\x00', bg_color=b'\x8B\x45\x13') # Orange on Brown
+ # bot.icon_field = pack_icon_appearance_field(icon_data) # Store for use in send/reply
+ # except Exception as e:
+ # print(f"Could not prepare icon field: {e}")
+ # bot.icon_field = None
+
+ if __name__ == "__main__":
+ print(f"Starting bot: {bot.config.name}")
+ print(f"Bot LXMF Address: {bot.local.hash}") # Prints the bot's address
+ bot.run()
+
+4. **(Optional) Add Your Admin Hash:**
+ * Find your LXMF address hash (e.g., from your Reticulum client like Sideband or NomadNet).
+ * Uncomment and edit the :code:`bot.config.admins.add(...)` line in :code:`my_first_bot.py`, replacing :code:`"your_lxmf_hash_here"` with your actual hash.
+
+5. **Run Your Bot:**
+
+ .. code-block:: bash
+
+ python my_first_bot.py
+
+ Your bot will start, print its LXMF address, potentially send an announce message over the Reticulum network, and begin listening for messages.
+
+Interacting With Your Bot
+-------------------------
+
+1. **Send a message** to the bot's LXMF address from your client.
+2. **Try the example command:** Send :code:`/hello` to the bot. It should reply with "Hello :code:`<your_hash>`!".
+ If you uncommented the icon example above, this reply might also carry an icon.
+3. **Try the help command:** Send :code:`/help`.
+
+Advanced Features
+-----------------
+
+Once you're comfortable with the basics, explore these advanced features:
+
+**Message Handlers:**
+
+* Use :code:`@bot.on_first_message()` to welcome new users
+* Use :code:`@bot.on_message()` to handle all messages before command processing
+
+**Reliable Delivery:**
+
+* Configure :code:`direct_delivery_retries` in :code:`LXMFBot(...)` for automatic retry before propagation fallback
+* Configure :code:`propagation_node` in bot config (or use :code:`bot.set_propagation_node(...)`) to route through a specific LXMF propagation node
+
+**Security:**
+
+* Enable :code:`signature_verification_enabled=True` to enforce LXMF's built-in signature verification
+* Set :code:`require_message_signatures=True` to reject unsigned or invalid messages
+* Note: LXMF automatically signs all messages; LXMFy just enforces verification policy
+
+See the `Creating Bots <creating-bots.html>`_ guide and `API Reference <api-reference.html>`_ for detailed information on these features.
+
+Next Steps
+----------
+
+* Explore the `Creating Bots <creating-bots.html>`_ guide for more details on adding commands, using cogs, and different bot types.
+* Check the `API Reference <api-reference.html>`_ for detailed information on framework components.

diff --git a/vendor/lxmfy/lxmfy/__init__.py b/vendor/lxmfy/lxmfy/__init__.py
new file mode 100644
index 00000000..56abb457
--- /dev/null
+++ b/vendor/lxmfy/lxmfy/__init__.py
@@ -0,0 +1,59 @@
+"""LXMFy - A bot framework for creating LXMF bots on the Reticulum Network.
+
+This package provides tools and utilities for creating and managing LXMF bots,
+including command handling, storage management, moderation features, and role-based permissions.
+"""
+
+from .attachments import (
+ Attachment,
+ AttachmentType,
+ IconAppearance,
+ pack_attachment,
+ pack_icon_appearance_field,
+)
+from .cogs_core import load_cogs_from_directory
+from .commands import Command, command
+from .config import BotConfig
+from .core import LXMFBot, BOT_DISPLAY_NAME_FILE
+from .events import Event, EventManager, EventPriority
+from .help import HelpFormatter, HelpSystem
+from .middleware import MiddlewareContext, MiddlewareManager, MiddlewareType
+from .permissions import DefaultPerms, PermissionManager, Role
+from .scheduler import ScheduledTask, TaskScheduler
+from .storage import JSONStorage, SQLiteStorage, Storage
+from .validation import format_validation_results, validate_bot
+
+__all__ = [
+ "Attachment",
+ "AttachmentType",
+ "BotConfig",
+ "Command",
+ "DefaultPerms",
+ "Event",
+ "EventManager",
+ "EventPriority",
+ "HelpFormatter",
+ "HelpSystem",
+ "IconAppearance",
+ "JSONStorage",
+ "LXMFBot",
+ "BOT_DISPLAY_NAME_FILE",
+ "MiddlewareContext",
+ "MiddlewareManager",
+ "MiddlewareType",
+ "PermissionManager",
+ "Role",
+ "SQLiteStorage",
+ "ScheduledTask",
+ "Storage",
+ "TaskScheduler",
+ "__version__",
+ "command",
+ "format_validation_results",
+ "load_cogs_from_directory",
+ "pack_attachment",
+ "pack_icon_appearance_field",
+ "validate_bot",
+]
+
+from .__version__ import __version__

diff --git a/vendor/lxmfy/lxmfy/__version__.py b/vendor/lxmfy/lxmfy/__version__.py
new file mode 100644
index 00000000..a3685971
--- /dev/null
+++ b/vendor/lxmfy/lxmfy/__version__.py
@@ -0,0 +1,17 @@
+import tomllib
+from importlib.metadata import PackageNotFoundError, version
+from pathlib import Path
+
+try:
+ __version__ = version("lxmfy")
+except PackageNotFoundError:
+ try:
+ pyproject_path = Path(__file__).parent.parent / "pyproject.toml"
+ if pyproject_path.exists():
+ with open(pyproject_path, "rb") as f:
+ pyproject = tomllib.load(f)
+ __version__ = pyproject["project"]["version"]
+ else:
+ __version__ = "1.6.2"
+ except Exception:
+ __version__ = "1.6.2"

diff --git a/vendor/lxmfy/lxmfy/attachments.py b/vendor/lxmfy/lxmfy/attachments.py
new file mode 100644
index 00000000..e481d0f7
--- /dev/null
+++ b/vendor/lxmfy/lxmfy/attachments.py
@@ -0,0 +1,128 @@
+from dataclasses import dataclass
+from enum import IntEnum
+
+import LXMF
+
+
+class AttachmentType(IntEnum):
+ """Enumerates the different types of attachments supported.
+
+ FILE: Represents a generic file attachment.
+ IMAGE: Represents an image attachment.
+ AUDIO: Represents an audio attachment.
+ """
+
+ FILE = 0x05
+ IMAGE = 0x06
+ AUDIO = 0x07
+
+
+@dataclass
+class Attachment:
+ """Represents a generic attachment.
+
+ Attributes:
+ type: The type of the attachment (AttachmentType).
+ name: The name of the attachment.
+ data: The binary data of the attachment.
+ format: Optional format specifier (e.g., "png" for images).
+
+ """
+
+ type: AttachmentType
+ name: str
+ data: bytes
+ format: str | None = None
+
+
+@dataclass
+class IconAppearance:
+ """Represents LXMF icon appearance data."""
+
+ icon_name: str
+ fg_color: bytes # Must be 3 bytes, e.g., b'\xff\x00\x00' for red
+ bg_color: bytes # Must be 3 bytes
+
+
+def create_file_attachment(filename: str, data: bytes) -> list:
+ """Create a file attachment list."""
+ return [filename, data]
+
+
+def create_image_attachment(image_format: str, data: bytes) -> list:
+ """Create an image attachment list."""
+ return [image_format, data]
+
+
+def create_audio_attachment(mode: int, data: bytes) -> list:
+ """Create an audio attachment list."""
+ return [mode, data]
+
+
+def pack_attachment(attachment: Attachment) -> dict:
+ """Packs an Attachment object into a dictionary suitable for LXMF transmission.
+
+ Args:
+ attachment: The Attachment object to pack.
+
+ Returns:
+ A dictionary containing the attachment data, formatted according to the
+ attachment type.
+
+ Raises:
+ ValueError: If the attachment type is not supported.
+
+ """
+ if attachment.type == AttachmentType.FILE:
+ return {
+ LXMF.FIELD_FILE_ATTACHMENTS: [
+ create_file_attachment(attachment.name, attachment.data),
+ ],
+ }
+ if attachment.type == AttachmentType.IMAGE:
+ return {
+ LXMF.FIELD_IMAGE: create_image_attachment(
+ attachment.format or "webp",
+ attachment.data,
+ ),
+ }
+ if attachment.type == AttachmentType.AUDIO:
+ try:
+ mode = int(attachment.format) if attachment.format is not None else 0
+ except (ValueError, TypeError):
+ mode = 0
+
+ return {
+ LXMF.FIELD_AUDIO: create_audio_attachment(
+ mode,
+ attachment.data,
+ ),
+ }
+ raise ValueError(f"Unsupported attachment type: {attachment.type}")
+
+
+def pack_icon_appearance_field(appearance: IconAppearance) -> dict:
+ """Packs an IconAppearance object into a dictionary suitable for LXMF transmission.
+
+ Args:
+ appearance: The IconAppearance object to pack.
+
+ Returns:
+ A dictionary containing the icon appearance data.
+
+ Raises:
+ ValueError: If fg_color or bg_color are not 3 bytes.
+
+ """
+ if not (isinstance(appearance.fg_color, bytes) and len(appearance.fg_color) == 3):
+ raise ValueError("fg_color must be 3 bytes (e.g., b'\\xff\\x00\\x00')")
+ if not (isinstance(appearance.bg_color, bytes) and len(appearance.bg_color) == 3):
+ raise ValueError("bg_color must be 3 bytes (e.g., b'\\x00\\xff\\x00')")
+
+ return {
+ LXMF.FIELD_ICON_APPEARANCE: [
+ appearance.icon_name,
+ appearance.fg_color,
+ appearance.bg_color,
+ ],
+ }

diff --git a/vendor/lxmfy/lxmfy/cli.py b/vendor/lxmfy/lxmfy/cli.py
new file mode 100644
index 00000000..bd3cd121
--- /dev/null
+++ b/vendor/lxmfy/lxmfy/cli.py
@@ -0,0 +1,738 @@
+"""CLI module for LXMFy bot framework.
+
+Provides an interactive and colorful command-line interface for creating and managing LXMF bots,
+including bot file creation and example cog generation.
+"""
+
+import argparse
+import os
+import re
+import sys
+
+from .__version__ import __version__
+from .colors import (
+ Colors,
+ init_colors,
+ print_error,
+ print_header,
+ print_info,
+ print_menu,
+ print_success,
+ print_warning,
+)
+from .templates import CogTestBot, EchoBot, NoteBot, ReminderBot
+
+
+def get_user_choice() -> str:
+ """Get user's choice from the menu."""
+ while True:
+ if Colors.is_colors_supported():
+ choice = input(f"{Colors.CYAN}Enter your choice (1-3): {Colors.ENDC}")
+ else:
+ choice = input("Enter your choice (1-3): ")
+ if choice in ["1", "2", "3"]:
+ return choice
+ print_error("Invalid choice. Please enter a number between 1 and 3.")
+
+
+def get_bot_name() -> str:
+ """Get bot name from user input."""
+ while True:
+ if Colors.is_colors_supported():
+ name = input(f"{Colors.CYAN}Enter bot name: {Colors.ENDC}")
+ else:
+ name = input("Enter bot name: ")
+ try:
+ return validate_bot_name(name)
+ except ValueError as ve:
+ print_error(f"Invalid bot name: {ve}")
+
+
+def get_template_choice() -> str:
+ """Get template choice from user input."""
+ templates = ["basic", "echo", "reminder", "note", "cogtest"]
+ if Colors.is_colors_supported():
+ print(f"\n{Colors.CYAN}Available templates:{Colors.ENDC}")
+ for i, template in enumerate(templates, 1):
+ print(f"{Colors.BOLD}{i}.{Colors.ENDC} {template}")
+ else:
+ print("\nAvailable templates:")
+ for i, template in enumerate(templates, 1):
+ print(f"{i}. {template}")
+
+ while True:
+ if Colors.is_colors_supported():
+ choice = input(f"\n{Colors.CYAN}Select template (1-5): {Colors.ENDC}")
+ else:
+ choice = input("\nSelect template (1-5): ")
+ if choice in ["1", "2", "3", "4", "5"]:
+ return templates[int(choice) - 1]
+ print_error("Invalid choice. Please enter a number between 1 and 5.")
+
+
+def interactive_create() -> None:
+ """Interactive bot creation process."""
+ print_header("Create New Bot")
+ bot_name = get_bot_name()
+ template = get_template_choice()
+
+ if Colors.is_colors_supported():
+ output_path = (
+ input(
+ f"{Colors.CYAN}Enter output path (default: {bot_name}.py): {Colors.ENDC}",
+ )
+ or f"{bot_name}.py"
+ )
+ else:
+ output_path = (
+ input(f"Enter output path (default: {bot_name}.py): ") or f"{bot_name}.py"
+ )
+
+ try:
+ bot_path = create_from_template(template, output_path, bot_name)
+ if template == "basic":
+ create_example_cog(bot_path)
+ print_success("Bot created successfully!")
+ print_info(f"""
+Files created:
+ - {bot_path} (main bot file)
+ - {os.path.join(os.path.dirname(bot_path), "cogs")}
+ - __init__.py
+ - basic.py (example cog)
+
+To start your bot:
+ python {bot_path}
+
+To add admin rights, edit {bot_path} and add your LXMF hash to the admins list.
+ """)
+ else:
+ print_success("Bot created successfully!")
+ print_info(f"""
+Files created:
+ - {bot_path} (main bot file)
+
+To start your bot:
+ python {bot_path}
+
+To add admin rights, edit {bot_path} and add your LXMF hash to the admins list.
+ """)
+ except Exception as e:
+ print_error(f"Error creating bot: {e!s}")
+
+
+def interactive_run() -> None:
+ """Interactive bot running process."""
+ print_header("Run Template Bot")
+ template = get_template_choice()
+
+ if Colors.is_colors_supported():
+ custom_name = input(f"{Colors.CYAN}Enter custom name (optional): {Colors.ENDC}")
+ else:
+ custom_name = input("Enter custom name (optional): ")
+ if custom_name:
+ try:
+ custom_name = validate_bot_name(custom_name)
+ except ValueError as ve:
+ print_warning(f"Invalid custom name provided. Using default. ({ve})")
+ custom_name = None
+
+ try:
+ template_map = {
+ "echo": EchoBot,
+ "reminder": ReminderBot,
+ "note": NoteBot,
+ "cogtest": CogTestBot,
+ }
+
+ BotClass = template_map[template]
+ print_header(f"Starting {template} Bot")
+ bot_instance = BotClass()
+
+ if custom_name:
+ if hasattr(bot_instance, "bot"):
+ bot_instance.bot.config.name = custom_name
+ bot_instance.bot.name = custom_name
+ else:
+ bot_instance.config.name = custom_name
+ bot_instance.name = custom_name
+ print_info(f"Running with custom name: {custom_name}")
+
+ bot_instance.run()
+ except Exception as e:
+ print_error(f"Error running template bot: {e!s}")
+
+
+def interactive_mode() -> None:
+ """Run the CLI in interactive mode."""
+ while True:
+ print_menu()
+ choice = get_user_choice()
+
+ if choice == "1":
+ interactive_create()
+ elif choice == "2":
+ interactive_run()
+ elif choice == "3":
+ print_success("Goodbye!")
+ sys.exit(0)
+
+ if Colors.is_colors_supported():
+ input(f"\n{Colors.CYAN}Press Enter to continue...{Colors.ENDC}")
+ else:
+ input("\nPress Enter to continue...")
+
+
+def sanitize_filename(filename: str) -> str:
+ """Sanitizes the filename while preserving the extension.
+
+ Args:
+ filename: The filename to sanitize.
+
+ Returns:
+ Sanitized filename with proper extension.
+
+ """
+ base, ext = os.path.splitext(os.path.basename(filename))
+ base = re.sub(r"[^a-zA-Z0-9\-_]", "", base)
+
+ if not ext or ext != ".py":
+ ext = ".py"
+
+ return f"{base}{ext}"
+
+
+def validate_bot_name(name: str) -> str:
+ """Validates and sanitizes a bot name.
+
+ Args:
+ name: The proposed bot name.
+
+ Returns:
+ The sanitized bot name.
+
+ Raises:
+ ValueError: If the name is invalid.
+
+ """
+ if not name:
+ raise ValueError("Bot name cannot be empty")
+
+ sanitized = "".join(c for c in name if c.isalnum() or c in " -_")
+ if not sanitized:
+ raise ValueError("Bot name must contain valid characters")
+
+ return sanitized
+
+
+def create_bot_file(name: str, output_path: str, no_cogs: bool = False) -> str:
+ """Creates a new bot file from a template.
+
+ Args:
+ name: The name for the bot.
+ output_path: The desired output path.
+ no_cogs: Whether to disable cogs loading.
+
+ Returns:
+ The path to the created bot file.
+
+ Raises:
+ RuntimeError: If file creation fails.
+
+ """
+ try:
+ name = validate_bot_name(name)
+
+ output_dir = os.path.dirname(output_path)
+ if output_dir:
+ os.makedirs(output_dir, exist_ok=True)
+
+ if output_path.endswith("/") or output_path.endswith("\\"):
+ base_name = "bot.py"
+ output_path = os.path.join(output_path, base_name)
+ elif not output_path.endswith(".py"):
+ output_path += ".py"
+
+ safe_path = os.path.abspath(output_path)
+
+ template = f"""from lxmfy import LXMFBot
+
+bot = LXMFBot(
+ name="{name}",
+ announce=600,
+ announce_immediately=True,
+ admins=set(),
+ hot_reloading=False,
+ rate_limit=5,
+ cooldown=60,
+ max_warnings=3,
+ warning_timeout=300,
+ command_prefix="/",
+ cogs_dir="cogs",
+ cogs_enabled={not no_cogs},
+ permissions_enabled=False,
+ storage_type="json",
+ storage_path="data",
+ first_message_enabled=True,
+ event_logging_enabled=True,
+ max_logged_events=1000,
+ event_middleware_enabled=True,
+ announce_enabled=True
+)
+
+if __name__ == "__main__":
+ bot.run()
+"""
+ with open(safe_path, "w", encoding="utf-8") as f:
+ f.write(template)
+
+ return os.path.relpath(safe_path)
+
+ except Exception as e:
+ raise RuntimeError(f"Failed to create bot file: {e!s}") from e
+
+
+def create_example_cog(bot_path: str) -> None:
+ """Creates an example cog and the necessary directory structure.
+
+ Args:
+ bot_path: The path to the bot file to determine the cogs location.
+
+ """
+ try:
+ bot_dir = os.path.dirname(os.path.abspath(bot_path))
+ cogs_dir = os.path.join(bot_dir, "cogs")
+ os.makedirs(cogs_dir, exist_ok=True)
+
+ init_path = os.path.join(cogs_dir, "__init__.py")
+ with open(init_path, "w", encoding="utf-8") as f:
+ f.write("")
+
+ template = """from lxmfy import Command
+
+class BasicCommands:
+ def __init__(self, bot):
+ self.bot = bot
+
+ @Command(name="hello", description="Says hello")
+ async def hello(self, ctx):
+ ctx.reply(f"Hello {ctx.sender}!")
+
+ @Command(name="about", description="About this bot")
+ async def about(self, ctx):
+ ctx.reply("I'm a bot created with LXMFy!")
+
+def setup(bot):
+ bot.add_cog(BasicCommands(bot))
+"""
+ basic_path = os.path.join(cogs_dir, "basic.py")
+ with open(basic_path, "w", encoding="utf-8") as f:
+ f.write(template)
+
+ except Exception as e:
+ raise RuntimeError(f"Failed to create example cog: {e!s}") from e
+
+
+def create_from_template(template_name: str, output_path: str, bot_name: str) -> str:
+ """Creates a bot from a template.
+
+ Args:
+ template_name: The name of the template to use.
+ output_path: The desired output path.
+ bot_name: The name for the bot.
+
+ Returns:
+ The path to the created bot file.
+
+ Raises:
+ ValueError: If the template is invalid.
+
+ """
+ try:
+ name = validate_bot_name(bot_name)
+ output_dir = os.path.dirname(output_path)
+ if output_dir:
+ os.makedirs(output_dir, exist_ok=True)
+
+ if output_path.endswith("/") or output_path.endswith("\\"):
+ base_name = "bot.py"
+ output_path = os.path.join(output_path, base_name)
+ elif not output_path.endswith(".py"):
+ output_path += ".py"
+
+ safe_path = os.path.abspath(output_path)
+
+ if template_name == "basic":
+ return create_bot_file(name, safe_path)
+
+ template_map = {
+ "echo": EchoBot,
+ "reminder": ReminderBot,
+ "note": NoteBot,
+ "cogtest": CogTestBot,
+ }
+
+ if template_name not in template_map:
+ raise ValueError(
+ f"Invalid template: {template_name}. Available templates: basic, {', '.join(template_map.keys())}",
+ )
+
+ template = f"""from lxmfy.templates import {template_map[template_name].__name__}
+
+if __name__ == "__main__":
+ bot = {template_map[template_name].__name__}()
+ bot.bot.name = "{name}" # Set custom name
+ bot.run()
+"""
+ with open(safe_path, "w", encoding="utf-8") as f:
+ f.write(template)
+
+ return os.path.relpath(safe_path)
+
+ except Exception as e:
+ raise RuntimeError(f"Failed to create bot from template: {e!s}") from e
+
+
+def is_safe_path(path: str, base_path: str = None) -> bool:
+ """Checks if a path is safe and within the allowed directory.
+
+ Args:
+ path: The path to check.
+ base_path: The base path to check against. If None, all paths are considered safe.
+
+ Returns:
+ True if the path is safe, False otherwise.
+
+ """
+ try:
+ if base_path:
+ base_path = os.path.abspath(base_path)
+ path = os.path.abspath(path)
+ return path.startswith(base_path)
+ return True
+ except Exception:
+ return False
+
+
+def main() -> None:
+ """Main CLI entry point."""
+ try:
+ init_colors()
+
+ if len(sys.argv) == 1:
+ interactive_mode()
+ return
+
+ print_header("LXMFy Bot Framework")
+
+ parser = argparse.ArgumentParser(
+ description=f"LXMFy Bot Tool (version {__version__})",
+ formatter_class=argparse.RawDescriptionHelpFormatter,
+ epilog="""
+Examples:
+ lxmfy create # Create basic bot file 'bot.py'
+ lxmfy create mybot # Create basic bot file 'mybot.py'
+ lxmfy create --template echo mybot # Create echo bot file 'mybot.py'
+ lxmfy create --template reminder bot # Create reminder bot file 'bot.py'
+ lxmfy create --template note notes # Create note-taking bot file 'notes.py'
+ lxmfy create --template cogtest test # Create cog test bot file 'test.py'
+
+ lxmfy run echo # Run the built-in echo bot
+ lxmfy run reminder --name "MyReminder" # Run the reminder bot with a custom name
+ lxmfy run note # Run the built-in note bot
+ lxmfy run cogtest # Run the cog test bot
+
+ lxmfy signatures test # Test signature functionality
+ lxmfy signatures enable # Show how to enable signatures
+ lxmfy signatures disable # Show how to disable signatures
+ """,
+ )
+
+ parser.add_argument(
+ "command",
+ choices=["create", "run", "signatures"],
+ help="Create a bot file, run a template bot, or manage signatures",
+ )
+ parser.add_argument(
+ "name",
+ nargs="?",
+ default=None,
+ help="Name for 'create' (bot name/path) or 'run' (template name: echo, reminder, note)",
+ )
+ parser.add_argument(
+ "directory",
+ nargs="?",
+ default=None,
+ help="Output directory for 'create' command (optional)",
+ )
+ parser.add_argument(
+ "--template",
+ choices=["basic", "echo", "reminder", "note", "cogtest"],
+ default="basic",
+ help="Bot template to use for 'create' command (default: basic)",
+ )
+ parser.add_argument(
+ "--name",
+ dest="name_opt",
+ default=None,
+ help="Optional custom name for the bot (used with 'create' or 'run')",
+ )
+ parser.add_argument(
+ "--output",
+ default=None,
+ help="Output file path or directory for 'create' command",
+ )
+ parser.add_argument(
+ "--no-cogs",
+ action="store_true",
+ help="Disable cogs loading for 'create' command",
+ )
+
+ args = parser.parse_args()
+
+ if args.command == "create":
+ try:
+ bot_name = args.name_opt or args.name or "MyLXMFBot"
+
+ if args.output:
+ output_path = args.output
+ elif args.directory:
+ output_path = os.path.join(args.directory, "bot.py")
+ elif args.name:
+ if "." in args.name:
+ output_path = args.name
+ if not args.name_opt:
+ bot_name = os.path.splitext(os.path.basename(args.name))[0]
+ else:
+ output_path = f"{args.name}.py"
+ else:
+ output_path = "bot.py"
+
+ try:
+ bot_name = validate_bot_name(bot_name)
+ except ValueError as ve:
+ print_error(f"Invalid bot name '{bot_name}'. {ve}")
+ sys.exit(1)
+
+ print_header("Creating New Bot")
+ bot_path = create_from_template(args.template, output_path, bot_name)
+
+ if args.template == "basic":
+ create_example_cog(bot_path)
+ print_success("Bot created successfully!")
+ print_info(f"""
+Files created:
+ - {bot_path} (main bot file)
+ - {os.path.join(os.path.dirname(bot_path), "cogs")}
+ - __init__.py
+ - basic.py (example cog)
+
+To start your bot:
+ python {bot_path}
+
+To add admin rights, edit {bot_path} and add your LXMF hash to the admins list.
+ """)
+ else:
+ print_success("Bot created successfully!")
+ print_info(f"""
+Files created:
+ - {bot_path} (main bot file)
+
+To start your bot:
+ python {bot_path}
+
+To add admin rights, edit {bot_path} and add your LXMF hash to the admins list.
+ """)
+ except Exception as e:
+ print_error(f"Error creating bot: {e!s}")
+ sys.exit(1)
+
+ elif args.command == "run":
+ template_name = args.name
+ if not template_name:
+ print_error(
+ "Please specify a template name to run (echo, reminder, note, cogtest)",
+ )
+ sys.exit(1)
+
+ template_map = {
+ "echo": EchoBot,
+ "reminder": ReminderBot,
+ "note": NoteBot,
+ "cogtest": CogTestBot,
+ }
+
+ if template_name not in template_map:
+ print_error(
+ f"Invalid template name '{template_name}'. Choose from: {', '.join(template_map.keys())}",
+ )
+ sys.exit(1)
+
+ try:
+ BotClass = template_map[template_name]
+ print_header(f"Starting {template_name} Bot")
+ bot_instance = BotClass()
+
+ custom_name = args.name_opt
+ if custom_name:
+ try:
+ validated_name = validate_bot_name(custom_name)
+ if hasattr(bot_instance, "bot"):
+ bot_instance.bot.config.name = validated_name
+ bot_instance.bot.name = validated_name
+ else:
+ bot_instance.config.name = validated_name
+ bot_instance.name = validated_name
+ print_info(f"Running with custom name: {validated_name}")
+ except ValueError as ve:
+ print_warning(
+ f"Invalid custom name '{custom_name}' provided. Using default. ({ve})",
+ )
+
+ bot_instance.run()
+
+ except Exception as e:
+ print_error(f"Error running template bot '{template_name}': {e!s}")
+ sys.exit(1)
+
+ elif args.command == "signatures":
+ try:
+ print_header("Signature Management")
+ if not args.name:
+ print_error("Please specify a subcommand: test, enable, disable")
+ print_info("Usage: lxmfy signatures <subcommand>")
+ print_info(
+ " test - Test signature verification with sample data",
+ )
+ print_info(" enable - Show how to enable signature verification")
+ print_info(
+ " disable - Show how to disable signature verification",
+ )
+ sys.exit(1)
+
+ subcommand = args.name
+
+ if subcommand == "test":
+ print_info("Testing signature functionality...")
+ try:
+ import RNS
+
+ from lxmfy.signatures import FIELD_SIGNATURE, SignatureManager
+
+ identity1 = RNS.Identity()
+ identity2 = RNS.Identity()
+
+ class MockBot:
+ def __init__(self):
+ self.permissions = MockPermissions()
+
+ class MockPermissions:
+ @staticmethod
+ def has_permission(user, perm):
+ return False # No bypass for testing
+
+ bot = MockBot()
+ sig_manager = SignatureManager(
+ bot,
+ verification_enabled=True,
+ require_signatures=False,
+ )
+
+ class MockMessage:
+ def __init__(
+ self,
+ source_hash,
+ dest_hash,
+ content,
+ title=None,
+ fields=None,
+ ):
+ self.source_hash = source_hash
+ self.destination_hash = dest_hash
+ self.content = content
+ self.title = title or b"Test"
+ self.fields = fields or {}
+
+ test_msg = MockMessage(
+ identity1.hash,
+ identity2.hash,
+ b"Hello, World!",
+ b"Test Message",
+ )
+
+ signature = sig_manager.sign_message(test_msg, identity1)
+ print_success(
+ f"OK Message signed successfully (signature length: {len(signature)} bytes)",
+ )
+
+ test_msg.fields[FIELD_SIGNATURE] = signature
+ is_valid = sig_manager.verify_message_signature(
+ test_msg,
+ signature,
+ RNS.hexrep(identity1.hash, delimit=False),
+ )
+ if is_valid:
+ print_success("OK Signature verification successful")
+ else:
+ print_error("FAIL Signature verification failed")
+
+ print_info("Signature test completed successfully!")
+
+ except Exception as e:
+ print_error(f"Signature test failed: {e!s}")
+ print_info("This may be due to RNS initialization requirements")
+ sys.exit(1)
+
+ elif subcommand == "enable":
+ print_info(
+ "To enable signature verification in your bot, add these parameters to your LXMFBot constructor:",
+ )
+ print()
+ print(
+ "signature_verification_enabled=True, # Enable signature checking",
+ )
+ print(
+ "require_message_signatures=False, # Set to True to reject unsigned messages",
+ "require_stamps=False, # Set to True to reject invalid stamps",
+ "request_unknown_identities=False, # Set to True to request unknown keys",
+ )
+ print()
+ print_info("Example:")
+ print("bot = LXMFBot(")
+ print(" name='MyBot',")
+ print(" signature_verification_enabled=True,")
+ print(" require_message_signatures=False,")
+ print(" require_stamps=False")
+ print(")")
+
+ elif subcommand == "disable":
+ print_info(
+ "Signature verification is disabled by default. To explicitly disable:",
+ )
+ print()
+ print(
+ "signature_verification_enabled=False, # Disable signature checking",
+ )
+ print(
+ "require_message_signatures=False, # Not required when disabled",
+ "require_stamps=False, # Set to True to still require stamps",
+ )
+ print()
+ print_info(
+ "Or simply omit these parameters (they default to False)",
+ )
+
+ else:
+ print_error(f"Unknown subcommand: {subcommand}")
+ print_info("Available subcommands: test, enable, disable")
+
+ except Exception as e:
+ print_error(f"Error in signatures command: {e!s}")
+ sys.exit(1)
+
+ except KeyboardInterrupt:
+ print("\nExiting...")
+ sys.exit(0)
+
+
+if __name__ == "__main__":
+ main()

diff --git a/vendor/lxmfy/lxmfy/cogs_core.py b/vendor/lxmfy/lxmfy/cogs_core.py
new file mode 100644
index 00000000..ee57ef8c
--- /dev/null
+++ b/vendor/lxmfy/lxmfy/cogs_core.py
@@ -0,0 +1,187 @@
+"""Cogs management module for LXMFy.
+
+This module provides functionality for loading and managing cogs (extension modules)
+in LXMFy bots. It handles dynamic loading of Python modules from a specified directory
+and manages their integration with the bot system.
+"""
+
+import os
+import shutil
+import subprocess
+import sys
+
+import RNS
+
+
+def _get_sandbox_command(bot, script_path):
+ """Determines the sandbox command to use for external cogs."""
+ if not bot.config.external_cogs_sandbox_enabled:
+ return None
+
+ if sys.platform != "linux":
+ return None
+
+ sandbox_type = bot.config.external_cogs_sandbox_type.lower()
+
+ # Detect available tools
+ bwrap_path = shutil.which("bwrap")
+ firejail_path = shutil.which("firejail")
+
+ if sandbox_type == "bwrap" or (sandbox_type == "auto" and bwrap_path):
+ if bwrap_path:
+ # Minimal bwrap sandbox
+ cmd = [
+ bwrap_path,
+ "--unshare-all",
+ "--new-session",
+ "--proc",
+ "/proc",
+ "--dev",
+ "/dev",
+ "--tmpfs",
+ "/tmp", # noqa: S108
+ "--ro-bind",
+ "/usr",
+ "/usr",
+ ]
+
+ # Handle merged-usr systems by creating symlinks if they are symlinks on host
+ for path in ["/bin", "/lib", "/lib64", "/sbin"]:
+ if os.path.islink(path):
+ target = os.readlink(path)
+ # If target is relative, we keep it relative, but bwrap --symlink takes (target, dest)
+ cmd.extend(["--symlink", target, path])
+ elif os.path.exists(path):
+ cmd.extend(["--ro-bind", path, path])
+
+ # Add /etc/alternatives for things like python/ruby symlinks
+ if os.path.exists("/etc/alternatives"):
+ cmd.extend(["--ro-bind", "/etc/alternatives", "/etc/alternatives"])
+
+ # Bind the script itself
+ cmd.extend(["--ro-bind", script_path, script_path])
+
+ return cmd
+
+ if sandbox_type == "firejail" or (sandbox_type == "auto" and firejail_path):
+ if firejail_path:
+ return [firejail_path, "--quiet", "--private", "--net=none", "--noprofile"]
+
+ return None
+
+
+def load_cogs_from_directory(bot, directory="cogs"):
+ """Loads all Python modules and executable scripts from a directory.
+
+ Args:
+ bot: The LXMFBot instance to load the cogs into.
+ directory (str): The directory name relative to the bot's config path. Defaults to "cogs".
+
+ Raises:
+ Exception: If there's an error loading any cog.
+
+ """
+ cogs_dir = os.path.join(bot.config_path, directory)
+
+ if not os.path.exists(cogs_dir):
+ os.makedirs(cogs_dir)
+ RNS.log(f"Created cogs directory: {cogs_dir}", RNS.LOG_INFO)
+ return
+
+ if cogs_dir not in sys.path:
+ sys.path.insert(0, os.path.dirname(cogs_dir))
+
+ for filename in os.listdir(cogs_dir):
+ if filename.startswith("_"):
+ continue
+
+ path = os.path.join(cogs_dir, filename)
+
+ if filename.endswith(".py"):
+ cog_name = f"{directory}.{filename[:-3]}"
+ try:
+ bot.load_extension(cog_name)
+ RNS.log(f"Loaded extension: {cog_name}", RNS.LOG_INFO)
+ except Exception as e: # pylint: disable=broad-except
+ RNS.log(f"Failed to load extension {cog_name}: {e!s}", RNS.LOG_ERROR)
+ elif bot.config.external_cogs_enabled and os.access(path, os.X_OK):
+ # Load as an external script cog
+ command_name = os.path.splitext(filename)[0]
+ try:
+ from .commands import Command
+
+ def create_handler(script_path, script_filename):
+ def handler(msg):
+ try:
+ env = os.environ.copy()
+ env["LXMFY_SENDER"] = msg.sender
+ env["LXMFY_CONTENT"] = msg.content
+ env["LXMFY_HAS_ADMIN"] = str(
+ getattr(msg, "is_admin", False),
+ ).lower()
+
+ # Prepare arguments: sender, content, and any existing args
+ script_args = [msg.sender, msg.content]
+ if hasattr(msg, "args") and msg.args:
+ script_args.extend([str(a) for a in msg.args])
+
+ # Apply sandbox if enabled and available
+ sandbox_cmd = _get_sandbox_command(bot, script_path)
+ if sandbox_cmd:
+ full_cmd = sandbox_cmd + [script_path] + script_args
+ else:
+ full_cmd = [script_path] + script_args
+
+ # Determine timeout (0 or None means no timeout)
+ timeout = bot.config.external_cogs_timeout
+ if timeout <= 0:
+ timeout = None
+
+ result = subprocess.run( # noqa: S603
+ full_cmd,
+ capture_output=True,
+ text=True,
+ env=env,
+ check=True,
+ timeout=timeout,
+ )
+ if result.stdout.strip():
+ msg.reply(result.stdout.strip())
+ if result.stderr.strip():
+ RNS.log(
+ f"External cog {script_filename} stderr: {result.stderr.strip()}",
+ RNS.LOG_DEBUG,
+ )
+ except subprocess.TimeoutExpired:
+ RNS.log(
+ f"External cog {script_filename} timed out after {bot.config.external_cogs_timeout}s",
+ RNS.LOG_ERROR,
+ )
+ msg.reply(f"Error: Command {script_filename} timed out.")
+ except subprocess.CalledProcessError as e:
+ RNS.log(
+ f"External cog {script_filename} failed with exit code {e.returncode}: {e.stderr}",
+ RNS.LOG_ERROR,
+ )
+ msg.reply(f"Error executing command: {script_filename}")
+ except Exception as e:
+ RNS.log(
+ f"Unexpected error executing external cog {script_filename}: {e!s}",
+ RNS.LOG_ERROR,
+ )
+
+ return handler
+
+ cmd = Command(
+ name=command_name,
+ description=f"External script command: {filename}",
+ threaded=True, # Always threaded for external processes
+ )
+ cmd.callback = create_handler(path, filename)
+ bot.commands[command_name] = cmd
+ RNS.log(f"Loaded external extension: {filename}", RNS.LOG_INFO)
+ except Exception as e:
+ RNS.log(
+ f"Failed to load external extension {filename}: {e!s}",
+ RNS.LOG_ERROR,
+ )

diff --git a/vendor/lxmfy/lxmfy/colors.py b/vendor/lxmfy/lxmfy/colors.py
new file mode 100644
index 00000000..2105a955
--- /dev/null
+++ b/vendor/lxmfy/lxmfy/colors.py
@@ -0,0 +1,230 @@
+"""Color support module for LXMFy CLI.
+
+Provides cross-platform color support for terminal output.
+"""
+
+import os
+import sys
+
+
+class Colors:
+ """ANSI color codes for terminal output.
+
+ Automatically handles Windows Virtual Terminal Processing initialization.
+ """
+
+ HEADER = "\033[95m"
+ BLUE = "\033[94m"
+ CYAN = "\033[96m"
+ GREEN = "\033[92m"
+ YELLOW = "\033[93m"
+ RED = "\033[91m"
+ ENDC = "\033[0m"
+ BOLD = "\033[1m"
+ UNDERLINE = "\033[4m"
+
+ _colors_enabled: bool | None = None
+ _windows_vt_enabled: bool = False
+
+ @classmethod
+ def enable_windows_colors(cls) -> bool:
+ """Enable ANSI color support on Windows 10/11.
+
+ Returns:
+ True if colors are supported, False otherwise.
+
+ """
+ if cls._windows_vt_enabled:
+ return True
+
+ if sys.platform != "win32":
+ cls._colors_enabled = True
+ return True
+
+ try:
+ import ctypes
+ from ctypes import wintypes
+
+ kernel32 = ctypes.windll.kernel32
+
+ STD_OUTPUT_HANDLE = -11
+ STD_ERROR_HANDLE = -12
+ ENABLE_VIRTUAL_TERMINAL_PROCESSING = 0x0004
+
+ for std_handle in [STD_OUTPUT_HANDLE, STD_ERROR_HANDLE]:
+ handle = kernel32.GetStdHandle(std_handle)
+ if handle == -1 or handle == 0:
+ continue
+
+ mode = wintypes.DWORD()
+ if not kernel32.GetConsoleMode(handle, ctypes.byref(mode)):
+ continue
+
+ mode.value |= ENABLE_VIRTUAL_TERMINAL_PROCESSING
+ if not kernel32.SetConsoleMode(handle, mode):
+ continue
+
+ cls._windows_vt_enabled = True
+ cls._colors_enabled = True
+ return True
+
+ except Exception:
+ cls._colors_enabled = False
+ return False
+
+ @classmethod
+ def is_colors_supported(cls) -> bool:
+ """Check if colors are supported in the current environment.
+
+ Returns:
+ True if colors are supported, False otherwise.
+
+ """
+ if cls._colors_enabled is not None:
+ return cls._colors_enabled
+
+ if sys.platform == "win32":
+ return cls.enable_windows_colors()
+
+ if not hasattr(sys.stdout, "isatty") or not sys.stdout.isatty():
+ cls._colors_enabled = False
+ return False
+
+ term = os.environ.get("TERM", "")
+ if term == "dumb" or not term:
+ cls._colors_enabled = False
+ return False
+
+ cls._colors_enabled = True
+ return True
+
+ @classmethod
+ def colorize(cls, text: str, *color_codes: str) -> str:
+ """Apply color codes to text if colors are supported.
+
+ Args:
+ text: The text to colorize.
+ *color_codes: One or more color codes to apply.
+
+ Returns:
+ Colorized text if supported, plain text otherwise.
+
+ """
+ if not cls.is_colors_supported():
+ return text
+
+ prefix = "".join(color_codes)
+ return f"{prefix}{text}{cls.ENDC}"
+
+ @classmethod
+ def strip_colors(cls, text: str) -> str:
+ """Remove ANSI color codes from text.
+
+ Args:
+ text: Text potentially containing ANSI codes.
+
+ Returns:
+ Text with ANSI codes removed.
+
+ """
+ import re
+
+ ansi_escape = re.compile(r"\033\[[0-9;]*m")
+ return ansi_escape.sub("", text)
+
+
+def init_colors() -> bool:
+ """Initialize color support for the current platform.
+
+ Call this at the start of your CLI application.
+
+ Returns:
+ True if colors are supported, False otherwise.
+
+ """
+ return Colors.is_colors_supported()
+
+
+def print_header(text: str) -> None:
+ """Print a formatted header with custom styling.
+
+ Args:
+ text: The header text to display.
+
+ """
+ if Colors.is_colors_supported():
+ print(f"\n{Colors.HEADER}{Colors.BOLD}{'=' * 50}{Colors.ENDC}")
+ print(f"{Colors.HEADER}{Colors.BOLD}{text.center(50)}{Colors.ENDC}")
+ print(f"{Colors.HEADER}{Colors.BOLD}{'=' * 50}{Colors.ENDC}\n")
+ else:
+ print(f"\n{'=' * 50}")
+ print(f"{text.center(50)}")
+ print(f"{'=' * 50}\n")
+
+
+def print_success(text: str) -> None:
+ """Print a success message with custom styling.
+
+ Args:
+ text: The success message to display.
+
+ """
+ if Colors.is_colors_supported():
+ print(f"{Colors.GREEN}{Colors.BOLD}✓ {text}{Colors.ENDC}")
+ else:
+ print(f"[SUCCESS] {text}")
+
+
+def print_error(text: str) -> None:
+ """Print an error message with custom styling.
+
+ Args:
+ text: The error message to display.
+
+ """
+ if Colors.is_colors_supported():
+ print(f"{Colors.RED}{Colors.BOLD}✗ {text}{Colors.ENDC}")
+ else:
+ print(f"[ERROR] {text}")
+
+
+def print_info(text: str) -> None:
+ """Print an info message with custom styling.
+
+ Args:
+ text: The info message to display.
+
+ """
+ if Colors.is_colors_supported():
+ print(f"{Colors.BLUE}{Colors.BOLD}ℹ {text}{Colors.ENDC}")
+ else:
+ print(f"[INFO] {text}")
+
+
+def print_warning(text: str) -> None:
+ """Print a warning message with custom styling.
+
+ Args:
+ text: The warning message to display.
+
+ """
+ if Colors.is_colors_supported():
+ print(f"{Colors.YELLOW}{Colors.BOLD}⚠ {text}{Colors.ENDC}")
+ else:
+ print(f"[WARNING] {text}")
+
+
+def print_menu() -> None:
+ """Print the interactive menu."""
+ print_header("LXMFy Bot Framework")
+ if Colors.is_colors_supported():
+ print(f"{Colors.CYAN}Available Commands:{Colors.ENDC}")
+ print(f"{Colors.BOLD}1.{Colors.ENDC} Create a new bot")
+ print(f"{Colors.BOLD}2.{Colors.ENDC} Run a template bot")
+ print(f"{Colors.BOLD}3.{Colors.ENDC} Exit")
+ else:
+ print("Available Commands:")
+ print("1. Create a new bot")
+ print("2. Run a template bot")
+ print("3. Exit")
+ print()

diff --git a/vendor/lxmfy/lxmfy/commands.py b/vendor/lxmfy/lxmfy/commands.py
new file mode 100644
index 00000000..cce920d3
--- /dev/null
+++ b/vendor/lxmfy/lxmfy/commands.py
@@ -0,0 +1,162 @@
+"""Command handling module for LXMFy.
+
+This module provides the core command handling functionality for LXMFy bots,
+including command registration, method decoration, and cog support.
+"""
+
+from dataclasses import dataclass
+
+from .permissions import BasePermission, DefaultPerms
+
+
+@dataclass
+class CommandHelp:
+ """Help metadata for a command"""
+
+ name: str
+ description: str
+ usage: str | None = None
+ examples: list[str] = None
+ category: str | None = None
+ aliases: list[str] = None
+
+
+class Command:
+ """A decorator class for bot commands.
+
+ This class is used to mark methods as bot commands and provide metadata
+ about the command such as its name, description, and permission requirements.
+
+ Attributes:
+ name (str): The name of the command
+ description (str): A description of what the command does
+ admin_only (bool): Whether the command is restricted to admin users
+ callback (callable): The function that implements the command
+
+ """
+
+ def __init__(
+ self,
+ name,
+ description="No description provided",
+ admin_only=False,
+ permissions: BasePermission | None = None,
+ usage=None,
+ examples=None,
+ category=None,
+ aliases=None,
+ threaded: bool = False,
+ ):
+ """Initialize a new Command.
+
+ Args:
+ name (str): The name of the command
+ description (str, optional): Description of the command. Defaults to "No description provided"
+ admin_only (bool, optional): Whether the command requires admin privileges. Defaults to False
+
+ """
+ self.name = name
+ self.description = description
+ self.admin_only = admin_only
+ self.permissions = permissions or (
+ DefaultPerms.ALL if admin_only else DefaultPerms.USE_COMMANDS
+ )
+ self.threaded = threaded
+ self.callback = None
+ self.help = CommandHelp(
+ name=name,
+ description=description,
+ usage=usage,
+ examples=examples or [],
+ category=category,
+ aliases=aliases or [],
+ )
+
+ def __call__(self, func):
+ """Decorate a function as a command.
+
+ Args:
+ func (callable): The function to be decorated
+
+ Returns:
+ callable: The decorated function
+
+ """
+ self.callback = func
+ func.command = self
+ return func
+
+ def __get__(self, obj, objtype=None):
+ """Support instance methods in command definitions.
+
+ This method enables the command decorator to work with instance methods
+ by properly binding the method to the instance.
+
+ Args:
+ obj: The instance that the command is bound to
+ objtype: The type of the instance
+
+ Returns:
+ Command: A new Command instance bound to the object
+
+ """
+ if obj is None:
+ return self
+ new_cmd = self.__class__(
+ name=self.name,
+ description=self.description,
+ admin_only=self.admin_only,
+ permissions=self.permissions,
+ usage=self.help.usage,
+ examples=self.help.examples,
+ category=self.help.category,
+ aliases=self.help.aliases,
+ threaded=self.threaded,
+ )
+ new_cmd.callback = self.callback.__get__(obj, objtype)
+ return new_cmd
+
+
+def command(*args, **kwargs):
+ """Shorthand decorator for creating Command instances.
+
+ This function provides a more concise way to create commands using the
+ @command decorator syntax instead of @Command().
+
+ Args:
+ *args: Positional arguments to pass to Command constructor
+ **kwargs: Keyword arguments to pass to Command constructor
+
+ Returns:
+ Command: A new Command instance
+
+ """
+ return Command(*args, **kwargs)
+
+
+class Cog:
+ """Base class for bot extension modules (cogs).
+
+ Cogs are used to organize bot commands and listeners into modular components.
+ Each cog represents a collection of related commands and functionality.
+
+ Attributes:
+ bot: The bot instance that this cog is attached to
+
+ """
+
+ def __init__(self, bot):
+ """Initialize a new Cog.
+
+ Args:
+ bot: The bot instance that this cog will be registered to
+
+ """
+ self.bot = bot
+
+ def has_permission(self, user: str, permission: DefaultPerms) -> bool:
+ """Check if user has specific permission"""
+ if not self.enabled: # If permissions are disabled, allow everything
+ return True
+ user_perms = self.get_user_permissions(user)
+ return bool(user_perms & permission)

diff --git a/vendor/lxmfy/lxmfy/config.py b/vendor/lxmfy/lxmfy/config.py
new file mode 100644
index 00000000..d378f44f
--- /dev/null
+++ b/vendor/lxmfy/lxmfy/config.py
@@ -0,0 +1,108 @@
+"""Configuration module for LXMFy."""
+
+import os
+from dataclasses import dataclass
+
+
+@dataclass
+class BotConfig:
+ """Configuration settings for LXMFBot.
+
+ Attributes:
+ name (str): The name of the bot. Defaults to "LXMFBot".
+ announce (int): The announce interval in seconds. Defaults to 600.
+ announce_immediately (bool): Whether to announce immediately on startup. Defaults to True.
+ admins (set): A set of admin identity hashes. Defaults to an empty set.
+ hot_reloading (bool): Whether to enable hot reloading of cogs. Defaults to False.
+ rate_limit (int): The maximum number of messages allowed per cooldown period. Defaults to 5.
+ cooldown (int): The cooldown period in seconds. Defaults to 60.
+ max_warnings (int): The maximum number of spam warnings before action is taken. Defaults to 3.
+ warning_timeout (int): The duration in seconds for which a spam warning is active. Defaults to 300.
+ command_prefix (str): The prefix for bot commands. Defaults to "/".
+ cogs_dir (str): The directory to load cogs from. Defaults to "cogs".
+ cogs_enabled (bool): Whether to enable cogs. Defaults to True.
+ permissions_enabled (bool): Whether to enable the permission system. Defaults to False.
+ storage_type (str): The type of storage to use ("json" or "sqlite"). Defaults to "json".
+ storage_path (str): The path to the storage file or directory. Defaults to "data".
+ first_message_enabled (bool): Whether to enable first message handling. Defaults to True.
+ event_logging_enabled (bool): Whether to enable event logging. Defaults to True.
+ max_logged_events (int): The maximum number of events to log. Defaults to 1000.
+ event_middleware_enabled (bool): Whether to enable event middleware. Defaults to True.
+ announce_enabled (bool): Whether to enable bot announcements. Defaults to True.
+ signature_verification_enabled (bool): Whether to enable cryptographic signature verification for incoming messages. Defaults to False.
+ require_message_signatures (bool): Whether to reject unsigned messages when signature verification is enabled. Defaults to False.
+ require_stamps (bool): Whether to reject messages with invalid stamps. Defaults to False.
+ request_unknown_identities (bool): Whether to request unknown identities from the network when a message is received from an unknown source. Defaults to False.
+ stamp_cost (int): The cost of stamps for messages. If set, required for incoming and applied to outgoing. None disables stamps. Defaults to None.
+ direct_delivery_retries (int): Number of times to retry direct delivery before falling back to propagation. Defaults to 3.
+ propagation_fallback_enabled (bool): Whether to use propagation nodes as fallback after direct delivery fails. Defaults to True.
+ propagation_node (str): The destination hash of the outbound propagation node. If None and autopeer_propagation is True, automatically discovers nodes. Defaults to None.
+ autopeer_propagation (bool): Whether to automatically discover and peer with propagation nodes from announces. Defaults to False.
+ autopeer_maxdepth (int): Maximum hop depth for auto-peering with propagation nodes. None = no limit. Defaults to 4.
+ enable_propagation_node (bool): Whether to run this bot as a propagation node. Defaults to False.
+ message_storage_limit_mb (float): Maximum storage for propagation node messages in megabytes. Only applies when enable_propagation_node is True. Defaults to 500 MB.
+ config_path (str): The path to the bot configuration directory. If None, defaults to "config" in the current working directory. Defaults to None.
+ reticulum_config_dir (str): The Reticulum config directory used for RNS shared instance/auth state. If None, falls back to config_path. Can also be set via LXMFY_RETICULUM_CONFIG_DIR.
+ test_mode (bool): Whether to run in test mode (skips RNS initialization). Defaults to False.
+ announce_display_name_file (str): Optional filename under config_path whose UTF-8 contents override the bot display name for LXMF delivery announces. If unset, ``bot_display_name.txt`` is read when present. Otherwise ``name`` is used.
+
+ """
+
+ name: str = "LXMFBot"
+ announce: int = 600
+ announce_immediately: bool = True
+ admins: set = None
+ hot_reloading: bool = False
+ rate_limit: int = 5
+ cooldown: int = 60
+ max_warnings: int = 3
+ warning_timeout: int = 300
+ command_prefix: str = "/"
+ cogs_dir: str = "cogs"
+ cogs_enabled: bool = True
+ permissions_enabled: bool = False
+ storage_type: str = "json"
+ storage_path: str = "data"
+ first_message_enabled: bool = True
+ event_logging_enabled: bool = True
+ max_logged_events: int = 1000
+ event_middleware_enabled: bool = True
+ announce_enabled: bool = True
+ signature_verification_enabled: bool = False
+ require_message_signatures: bool = False
+ require_stamps: bool = False
+ request_unknown_identities: bool = False
+ stamp_cost: int = None
+ direct_delivery_retries: int = 3
+ propagation_fallback_enabled: bool = True
+ propagation_node: str = None
+ autopeer_propagation: bool = False
+ autopeer_maxdepth: int = 4
+ enable_propagation_node: bool = False
+ message_storage_limit_mb: float = 500.0
+ config_path: str = None
+ reticulum_config_dir: str = None
+ announce_display_name_file: str = None
+ test_mode: bool = False
+ identity_pinning_enabled: bool = False
+ message_persistence_enabled: bool = False
+ dynamic_cogs_enabled: bool = True
+ external_cogs_enabled: bool = True
+ external_cogs_sandbox_enabled: bool = True
+ external_cogs_sandbox_type: str = "auto" # 'auto', 'bwrap', 'firejail', 'none'
+ external_cogs_timeout: int = 30
+ nlp_enabled: bool = False
+ nlp_threshold: float = 0.5
+ link_support_enabled: bool = False
+ opportunistic_sending: bool = True
+
+ def __post_init__(self):
+ """Post-initialization to ensure admins is a set."""
+ if self.admins is None:
+ self.admins = set()
+ if self.reticulum_config_dir is None:
+ self.reticulum_config_dir = os.environ.get("LXMFY_RETICULUM_CONFIG_DIR")
+
+ def __str__(self):
+ """Return a string representation of the BotConfig object."""
+ return f"BotConfig(name={self.name}, announce={self.announce}, announce_immediately={self.announce_immediately}, admins={self.admins}, hot_reloading={self.hot_reloading}, rate_limit={self.rate_limit}, cooldown={self.cooldown}, max_warnings={self.max_warnings}, warning_timeout={self.warning_timeout}, command_prefix={self.command_prefix}, cogs_dir={self.cogs_dir}, cogs_enabled={self.cogs_enabled}, permissions_enabled={self.permissions_enabled}, storage_type={self.storage_type}, storage_path={self.storage_path}, first_message_enabled={self.first_message_enabled}, event_logging_enabled={self.event_logging_enabled}, max_logged_events={self.max_logged_events}, event_middleware_enabled={self.event_middleware_enabled}, announce_enabled={self.announce_enabled}, signature_verification_enabled={self.signature_verification_enabled}, require_message_signatures={self.require_message_signatures}, require_stamps={self.require_stamps}, request_unknown_identities={self.request_unknown_identities}, stamp_cost={self.stamp_cost}, test_mode={self.test_mode}, identity_pinning_enabled={self.identity_pinning_enabled}, message_persistence_enabled={self.message_persistence_enabled}, dynamic_cogs_enabled={self.dynamic_cogs_enabled})"

diff --git a/vendor/lxmfy/lxmfy/core.py b/vendor/lxmfy/lxmfy/core.py
new file mode 100644
index 00000000..a36715b7
--- /dev/null
+++ b/vendor/lxmfy/lxmfy/core.py
@@ -0,0 +1,1296 @@
+"""Core module for LXMFy bot framework.
+
+This module provides the main LXMFBot class that handles message routing,
+command processing, and bot lifecycle management for LXMF-based bots on
+the Reticulum Network.
+"""
+
+import importlib
+import inspect
+import logging
+import os
+import re
+import sys
+import time
+from concurrent.futures import ThreadPoolExecutor
+from queue import Queue
+from typing import Callable
+from types import SimpleNamespace
+
+import RNS
+from LXMF import LXMessage, LXMRouter
+
+from .attachments import Attachment, pack_attachment
+from .cogs_core import load_cogs_from_directory
+from .commands import Command
+from .config import BotConfig
+from .events import Event, EventManager, EventPriority
+from .help import HelpSystem
+from .middleware import MiddlewareContext, MiddlewareManager, MiddlewareType
+from .moderation import SpamProtection
+from .nlp import IntentClassifier
+from .permissions import DefaultPerms, PermissionManager
+from .scheduler import TaskScheduler
+from .signatures import SignatureManager, sign_outgoing_message, verify_incoming_message
+from .storage import JSONStorage, MemoryStorage, SQLiteStorage, Storage
+from .transport import Transport
+from .validation import format_validation_results, validate_bot
+
+BOT_DISPLAY_NAME_FILE = "bot_display_name.txt"
+
+
+class LXMFBot:
+ """Main bot class for handling LXMF messages and commands.
+
+ This class manages the bot's lifecycle, including:
+ - Message routing and delivery
+ - Command registration and execution
+ - Cog (extension) loading and management
+ - Spam protection
+ - Admin privileges
+ """
+
+ def __init__(self, **kwargs):
+ """Initialize a new LXMFBot instance.
+
+ Args:
+ **kwargs: Override default configuration settings
+
+ """
+ self.config = BotConfig(**kwargs)
+ self.commands = {}
+ self.cogs = {}
+ self.first_message_handlers = []
+ self.message_handlers = []
+ self.delivery_callbacks = []
+ self.receipts = []
+ self.queue = Queue(maxsize=50)
+ self.announce_time = 600
+ self.router = None
+ self.local = None
+ self.logger = logging.getLogger(__name__)
+ self.thread_pool = ThreadPoolExecutor(
+ max_workers=5,
+ ) # For offloading CPU-bound or blocking I/O tasks
+ self.scheduler = TaskScheduler(self) # Initialize the scheduler
+
+ if self.config.config_path:
+ self.config_path = self.config.config_path
+ else:
+ self.config_path = os.path.join(os.getcwd(), "config")
+
+ os.makedirs(self.config_path, exist_ok=True)
+ if self.config.reticulum_config_dir:
+ self.reticulum_config_dir = os.path.abspath(
+ os.path.expanduser(self.config.reticulum_config_dir),
+ )
+ else:
+ self.reticulum_config_dir = self.config_path
+ os.makedirs(self.reticulum_config_dir, exist_ok=True)
+
+ if self.config.storage_type == "json":
+ self.storage = Storage(JSONStorage(self.config.storage_path))
+ elif self.config.storage_type == "sqlite":
+ self.storage = Storage(SQLiteStorage(self.config.storage_path))
+ elif self.config.storage_type == "memory":
+ self.storage = Storage(MemoryStorage())
+
+ self.permissions = PermissionManager(
+ storage=self.storage,
+ enabled=self.config.permissions_enabled,
+ )
+
+ self.events = EventManager(self.storage)
+
+ self._register_builtin_events()
+
+ self.middleware = MiddlewareManager()
+
+ self.cogs_dir = os.path.join(self.config_path, self.config.cogs_dir)
+ os.makedirs(self.cogs_dir, exist_ok=True)
+
+ init_file = os.path.join(self.cogs_dir, "__init__.py")
+ if not os.path.exists(init_file):
+ open(init_file, "w", encoding="utf-8").close()
+
+ self.transport = Transport(self, self.storage)
+ self.spam_protection = SpamProtection(
+ storage=self.storage,
+ bot=self,
+ rate_limit=self.config.rate_limit,
+ cooldown=self.config.cooldown,
+ max_warnings=self.config.max_warnings,
+ warning_timeout=self.config.warning_timeout,
+ )
+
+ self._load_delivery_attempts()
+
+ identity_file = os.path.join(self.config_path, "identity")
+
+ if not self.config.test_mode:
+ # Initialize Reticulum (will raise exception if already running)
+ try:
+ RNS.Reticulum(
+ configdir=self.reticulum_config_dir,
+ loglevel=RNS.LOG_VERBOSE,
+ )
+ except OSError as e:
+ if "reinitialise" in str(e).lower():
+ # Reticulum already running, continue
+ pass
+ else:
+ raise
+
+ if not os.path.isfile(identity_file):
+ RNS.log("No Primary Identity file found, creating new...", RNS.LOG_INFO)
+ identity = RNS.Identity(True)
+ identity.to_file(identity_file)
+ self.identity = RNS.Identity.from_file(identity_file)
+ RNS.log("Loaded identity from file", RNS.LOG_INFO)
+
+ self.router = LXMRouter(
+ identity=self.identity,
+ storagepath=self.config_path,
+ autopeer=self.config.autopeer_propagation,
+ autopeer_maxdepth=self.config.autopeer_maxdepth,
+ enforce_stamps=self.config.require_stamps,
+ )
+ self.local = self.router.register_delivery_identity(
+ self.identity,
+ display_name=self.config.name,
+ stamp_cost=self.config.stamp_cost,
+ )
+ self._sync_delivery_display_name()
+ self.router.register_delivery_callback(self._message_received)
+ self.local.set_link_established_callback(self._link_established)
+
+ if self.router and self.config.enable_propagation_node:
+ try:
+ self.router.enable_propagation(
+ enforce_stamps=self.config.require_stamps,
+ )
+
+ if self.config.message_storage_limit_mb > 0:
+ self.router.set_message_storage_limit(
+ megabytes=self.config.message_storage_limit_mb,
+ )
+ RNS.log(
+ f"Set propagation node message storage limit to {self.config.message_storage_limit_mb} MB",
+ RNS.LOG_INFO,
+ )
+
+ RNS.log(
+ f"Enabled propagation node mode on {RNS.prettyhexrep(self.local.hash) if self.local else 'unknown'}",
+ RNS.LOG_INFO,
+ )
+ except Exception as e:
+ RNS.log(
+ f"Failed to enable propagation node: {e}",
+ RNS.LOG_ERROR,
+ )
+
+ if self.router and self.config.propagation_node:
+ try:
+ propagation_node_bytes = bytes.fromhex(self.config.propagation_node)
+ self.router.set_outbound_propagation_node(propagation_node_bytes)
+ RNS.log(
+ f"Configured outbound propagation node: {RNS.prettyhexrep(propagation_node_bytes)}",
+ RNS.LOG_INFO,
+ )
+ except ValueError:
+ RNS.log(
+ f"Invalid propagation node hash format: {self.config.propagation_node}",
+ RNS.LOG_ERROR,
+ )
+ elif self.router and self.config.autopeer_propagation:
+ RNS.log(
+ f"Auto-peering enabled for propagation nodes within {self.config.autopeer_maxdepth} hops",
+ RNS.LOG_INFO,
+ )
+ elif (
+ self.config.propagation_fallback_enabled
+ and not self.config.enable_propagation_node
+ ):
+ RNS.log(
+ "Propagation fallback is enabled but no propagation_node configured and autopeer_propagation is disabled. "
+ "Propagated delivery will fail. Set propagation_node, enable autopeer_propagation, or disable propagation_fallback_enabled.",
+ RNS.LOG_WARNING,
+ )
+
+ if self.local:
+ RNS.log(
+ f"LXMF Router ready to receive on: {RNS.prettyhexrep(self.local.hash)}",
+ RNS.LOG_INFO,
+ )
+ else:
+ # Test mode - create mock components
+ if os.path.isfile(identity_file):
+ self.identity = RNS.Identity.from_file(identity_file)
+ else:
+ self.identity = RNS.Identity() # Create a basic identity for testing
+ if self.config.config_path:
+ self.identity.to_file(identity_file)
+
+ self.router = None
+ self.local = None
+
+ self.announce_enabled = self.config.announce_enabled
+ self.announce_time = self.config.announce
+
+ if self.announce_enabled and not self.config.test_mode:
+ # Schedule the announce task
+ self.scheduler.add_task(
+ "announce_task",
+ self.announce_now,
+ f"*/{self.announce_time // 60} * * * *", # Convert seconds to minutes for cron
+ )
+ if self.config.announce_immediately:
+ self.announce_now(force=True)
+ RNS.log("Initial announce sent", RNS.LOG_INFO)
+
+ self.admins = set(self.config.admins or [])
+ self.hot_reloading = self.config.hot_reloading
+ self.command_prefix = self.config.command_prefix
+
+ self.help_system = HelpSystem(self)
+
+ self.nlp = IntentClassifier(threshold=self.config.nlp_threshold)
+ self.intents = {} # {intent_name: callback}
+
+ self.link_handlers = []
+ self.links = {} # {dest_hash: Link}
+
+ self.signature_manager = SignatureManager(
+ self,
+ verification_enabled=self.config.signature_verification_enabled,
+ require_signatures=self.config.require_message_signatures,
+ request_unknown_identities=self.config.request_unknown_identities,
+ )
+
+ self._load_delivery_attempts()
+ self._load_persisted_queue()
+
+ if self.config.cogs_enabled:
+ load_cogs_from_directory(self)
+
+ @property
+ def name(self) -> str:
+ """Bot display name used for LXMF when no file override applies."""
+ return self.config.name
+
+ @name.setter
+ def name(self, value: str) -> None:
+ self.config.name = value
+ self._sync_delivery_display_name()
+
+ def _effective_announce_display_name(self) -> str:
+ """Resolve the display name for lxmf/delivery announce app_data."""
+ if self.config.announce_display_name_file:
+ path = os.path.join(
+ self.config_path,
+ self.config.announce_display_name_file,
+ )
+ if os.path.isfile(path):
+ try:
+ with open(path, encoding="utf-8") as f:
+ text = f.read().strip()
+ if text:
+ return text
+ except OSError:
+ pass
+
+ default_path = os.path.join(self.config_path, BOT_DISPLAY_NAME_FILE)
+ if os.path.isfile(default_path):
+ try:
+ with open(default_path, encoding="utf-8") as f:
+ text = f.read().strip()
+ if text:
+ return text
+ except OSError:
+ pass
+
+ return self.config.name if self.config.name else "LXMFBot"
+
+ def _sync_delivery_display_name(self) -> None:
+ if not self.local:
+ return
+ self.local.display_name = self._effective_announce_display_name()
+
+ def command(self, *args, **kwargs):
+ """Decorator for registering commands.
+
+ Args:
+ *args: Command name (optional).
+ **kwargs: Command attributes (name, description, admin_only).
+
+ """
+
+ def decorator(func):
+ """The actual decorator that registers the command."""
+ name = args[0] if len(args) > 0 else kwargs.get("name", func.__name__)
+
+ description = kwargs.get("description", "No description provided")
+ admin_only = kwargs.get("admin_only", False)
+
+ cmd = Command(name=name, description=description, admin_only=admin_only)
+ cmd.callback = func
+ self.commands[name] = cmd
+ return func
+
+ return decorator
+
+ def load_extension(self, name: str) -> None:
+ """Load an extension (cog) by name.
+
+ Args:
+ name: The name of the extension to load.
+
+ Raises:
+ ValueError: If the module name contains invalid characters.
+ ImportError: If the extension is missing setup function or fails to load.
+
+ """
+ if not re.match(r"^[a-zA-Z0-9_\.]+$", name):
+ raise ValueError(f"Invalid module name format: {name}")
+
+ if not name.startswith("cogs."):
+ name = f"cogs.{name}"
+
+ try:
+ if self.hot_reloading and name in sys.modules:
+ module = importlib.reload(sys.modules[name])
+ else:
+ module = importlib.import_module(name)
+
+ if not hasattr(module, "setup"):
+ raise ImportError(f"Extension {name} missing setup function")
+ module.setup(self)
+ except ImportError as e:
+ raise ImportError(f"Failed to load extension {name}: {e!s}") from e
+
+ def add_cog(self, cog):
+ """Add a cog to the bot.
+
+ Args:
+ cog: The cog instance to add.
+
+ """
+ self.cogs[cog.__class__.__name__] = cog
+ for _name, method in inspect.getmembers(
+ cog,
+ predicate=lambda x: hasattr(x, "command"),
+ ):
+ if _name.startswith("_") or _name == "bot":
+ continue
+
+ try:
+ cmd_descriptor = method.command
+
+ if hasattr(cmd_descriptor, "__get__") and hasattr(
+ cmd_descriptor,
+ "name",
+ ):
+ cmd = cmd_descriptor.__get__(cog, cog.__class__)
+ elif hasattr(cmd_descriptor, "name"):
+ cmd = cmd_descriptor
+ if cmd.callback is None:
+ cmd.callback = method
+ else:
+ self.logger.warning(
+ "Unexpected command type for %s: %s",
+ _name,
+ type(cmd_descriptor),
+ )
+ continue
+
+ self.commands[cmd.name] = cmd
+ except Exception as e:
+ self.logger.error(
+ "Error adding command %s from cog %s: %s",
+ _name,
+ cog.__class__.__name__,
+ e,
+ )
+ continue
+
+ def remove_cog(self, cog_name: str) -> None:
+ """Remove a cog from the bot by its class name.
+
+ Args:
+ cog_name: The name of the cog class to remove.
+
+ """
+ if cog_name in self.cogs:
+ cog = self.cogs.pop(cog_name)
+ # Remove associated commands
+ commands_to_remove = [
+ name
+ for name, cmd in self.commands.items()
+ if hasattr(cmd, "callback")
+ and (
+ getattr(cmd.callback, "__self__", None) == cog
+ or (
+ hasattr(cmd.callback, "__func__")
+ and getattr(cmd.callback, "__self__", None) == cog
+ )
+ )
+ ]
+ for name in commands_to_remove:
+ del self.commands[name]
+
+ def reload_extension(self, name: str) -> None:
+ """Reload an extension (cog) by name."""
+ if not name.startswith("cogs."):
+ ext_name = f"cogs.{name}"
+ else:
+ ext_name = name
+
+ # Find the cog associated with this extension to remove it first
+ for cname, cog in list(self.cogs.items()):
+ if cog.__module__ == ext_name:
+ self.remove_cog(cname)
+ break
+
+ self.load_extension(name)
+
+ def is_admin(self, sender):
+ """Check if a sender is an admin.
+
+ Args:
+ sender: The sender's identity hash.
+
+ Returns:
+ True if the sender is an admin, False otherwise.
+
+ """
+ return sender in self.admins
+
+ def _register_builtin_events(self):
+ """Register built-in event handlers."""
+
+ @self.events.on("message_received", EventPriority.HIGHEST)
+ def handle_message(event):
+ """Handles incoming messages, performing spam checks."""
+ sender = event.data["sender"]
+ if not self.permissions.has_permission(sender, DefaultPerms.BYPASS_SPAM):
+ allowed, msg = self.spam_protection.check_spam(sender)
+ if not allowed:
+ event.cancel()
+ self.send(sender, msg)
+ return
+
+ self._reset_delivery_attempts(sender)
+
+ def _process_message(self, message, sender):
+ """Process an incoming message."""
+ try:
+ content = message.content.decode("utf-8")
+ receipt = RNS.hexrep(message.hash, delimit=False)
+
+ def reply(response, **kwargs):
+ """Helper function to reply to a message."""
+ self.send(sender, response, **kwargs)
+
+ if self.config.first_message_enabled:
+ first_messages = self.storage.get("first_messages", {})
+ if sender not in first_messages:
+ first_messages[sender] = True
+ self.storage.set("first_messages", first_messages)
+ handled = False
+ for handler in self.first_message_handlers:
+ if handler(sender, message):
+ handled = True
+ break
+ if handled:
+ return
+
+ if not self.permissions.has_permission(sender, DefaultPerms.USE_BOT):
+ return
+
+ # Call message handlers
+ for handler in self.message_handlers:
+ if handler(sender, message):
+ return
+
+ msg_ctx = {
+ "lxmf": message,
+ "reply": reply,
+ "sender": sender,
+ "content": content,
+ "hash": receipt,
+ }
+ msg = SimpleNamespace(**msg_ctx)
+
+ ctx = MiddlewareContext(MiddlewareType.PRE_COMMAND, msg)
+ if self.middleware.execute(MiddlewareType.PRE_COMMAND, ctx) is None:
+ return
+
+ if self.command_prefix is None or content.startswith(self.command_prefix):
+ command_name = (
+ content.split()[0][len(self.command_prefix) :]
+ if self.command_prefix
+ else content.split()[0]
+ )
+ if command_name in self.commands:
+ cmd = self.commands[command_name]
+
+ if not self.permissions.has_permission(sender, cmd.permissions):
+ self.send(
+ sender,
+ "You don't have permission to use this command.",
+ )
+ return
+
+ try:
+ args = content.split()[1:] if len(content.split()) > 1 else []
+
+ sig = inspect.signature(cmd.callback)
+ params = list(sig.parameters.values())
+
+ converted_args = []
+ for i, arg_val in enumerate(args):
+ param_idx = i + 1
+ if param_idx < len(params):
+ param = params[param_idx]
+ annotation = param.annotation
+ if (
+ annotation != inspect.Parameter.empty
+ and hasattr(annotation, "__call__")
+ and not isinstance(annotation, str)
+ ):
+ try:
+ converted_args.append(annotation(arg_val))
+ except (ValueError, TypeError):
+ converted_args.append(arg_val)
+ else:
+ converted_args.append(arg_val)
+ else:
+ converted_args.append(arg_val)
+
+ msg.args = converted_args
+ msg.is_admin = sender in self.admins
+
+ if cmd.threaded:
+ self.thread_pool.submit(cmd.callback, msg)
+ else:
+ cmd.callback(msg)
+
+ self.middleware.execute(MiddlewareType.POST_COMMAND, msg)
+ return
+
+ except Exception as e:
+ self.logger.error(
+ "Error executing command %s: %s",
+ command_name,
+ str(e),
+ )
+ self.send(sender, "Error executing command: %s", str(e))
+ return
+
+ # NLP Intent matching
+ if self.config.nlp_enabled:
+ intent_name, score = self.nlp.predict(content)
+ if intent_name and intent_name in self.intents:
+ self.logger.debug(
+ "NLP Intent Matched: %s (score: %.2f)",
+ intent_name,
+ score,
+ )
+ msg.intent = intent_name
+ msg.intent_score = score
+ try:
+ self.intents[intent_name](msg)
+ return
+ except Exception as e:
+ self.logger.error(
+ "Error executing intent %s: %s",
+ intent_name,
+ e,
+ )
+
+ for callback in self.delivery_callbacks:
+ callback(msg)
+
+ except Exception as e:
+ self.logger.error("Error processing message: %s", str(e))
+
+ def _message_received(self, message):
+ """Handle received messages."""
+ try:
+ sender = RNS.hexrep(message.source_hash, delimit=False)
+ receipt = RNS.hexrep(message.hash, delimit=False)
+
+ if receipt in self.receipts:
+ return
+
+ self.receipts.append(receipt)
+ if len(self.receipts) > 100:
+ self.receipts = self.receipts[-100:]
+
+ event_data = {
+ "message": message,
+ "sender": sender,
+ "receipt": receipt,
+ }
+
+ ctx = MiddlewareContext(MiddlewareType.PRE_EVENT, event_data)
+ if self.middleware.execute(MiddlewareType.PRE_EVENT, ctx) is None:
+ return
+
+ event = Event("message_received", event_data)
+ self.events.dispatch(event)
+
+ if not event.cancelled:
+ # Verify message signature if enabled
+ if verify_incoming_message(self, message, sender):
+ self._process_message(message, sender)
+ else:
+ RNS.log(
+ f"Rejected message from {sender} due to invalid signature",
+ RNS.LOG_WARNING,
+ )
+
+ except Exception as e:
+ self.logger.error("Error handling received message: %s", str(e))
+
+ def announce_now(self, force: bool = False) -> None:
+ """Send an LXMF delivery announce using the current display name.
+
+ LXMF builds delivery announce app_data from the destination display name
+ at announce time; this method refreshes that from :attr:`name`, optional
+ ``announce_display_name_file``, or ``bot_display_name.txt`` before
+ sending.
+
+ Args:
+ force: If True, send now and skip the on-disk announce interval
+ throttle (still respects ``announce_enabled`` and requires a
+ running router). If False, behave like the periodic announce
+ task (honours ``announce_time`` and the throttle file).
+
+ """
+ if self.config.test_mode or not self.local:
+ RNS.log("Announce skipped (test mode or no router)", RNS.LOG_DEBUG)
+ return
+ if not self.announce_enabled:
+ RNS.log("Announcements disabled", RNS.LOG_DEBUG)
+ return
+ if not force and self.announce_time == 0:
+ RNS.log("Announcements disabled", RNS.LOG_DEBUG)
+ return
+
+ announce_path = os.path.join(self.config_path, "announce")
+ if not force:
+ if os.path.isfile(announce_path):
+ with open(announce_path) as f:
+ try:
+ announce = int(f.readline())
+ except ValueError:
+ announce = 0
+ else:
+ announce = 0
+
+ if announce > int(time.time()):
+ RNS.log("Recent announcement", RNS.LOG_DEBUG)
+ return
+
+ with open(announce_path, "w+") as af:
+ interval = self.announce_time if self.announce_time > 0 else 0
+ next_announce = int(time.time()) + interval
+ af.write(str(next_announce))
+
+ self._sync_delivery_display_name()
+ self.local.announce()
+ RNS.log(
+ f"Announcement sent, next announce in {self.announce_time} seconds",
+ RNS.LOG_INFO,
+ )
+
+ def _load_delivery_attempts(self):
+ """Load delivery attempts from storage."""
+ self.delivery_attempts = self.storage.get("delivery_attempts", {})
+
+ def _save_delivery_attempts(self):
+ """Save delivery attempts to storage."""
+ self.storage.set("delivery_attempts", self.delivery_attempts)
+
+ def _reset_delivery_attempts(self, destination: str):
+ """Reset delivery attempts for a destination when they come back online.
+
+ Args:
+ destination: The destination hash.
+
+ """
+ if (
+ destination in self.delivery_attempts
+ and self.delivery_attempts[destination] > 0
+ ):
+ self.delivery_attempts[destination] = 0
+ self._save_delivery_attempts()
+ RNS.log(
+ f"Reset delivery attempts for {destination} (user came back online)",
+ RNS.LOG_DEBUG,
+ )
+
+ def send(
+ self,
+ destination: str,
+ message: str,
+ title: str = "Reply",
+ lxmf_fields: dict | None = None,
+ stamp_cost: int | None = None,
+ opportunistic: bool | None = None,
+ ):
+ """Send a message to a destination, optionally with custom LXMF fields.
+
+ Args:
+ destination: The destination hash.
+ message: The message content (will be utf-8 encoded).
+ title: The message title (optional, will be utf-8 encoded).
+ lxmf_fields: Optional dictionary of LXMF fields.
+ stamp_cost: Optional stamp cost override. If None, uses config.stamp_cost.
+ opportunistic: Whether to use opportunistic sending (try direct, then prop).
+ If None, uses config.opportunistic_sending.
+
+ """
+ if self.config.test_mode:
+ # In test mode, just queue a mock message
+ mock_message = SimpleNamespace()
+ mock_message.content = message.encode("utf-8")
+ mock_message.title = title.encode("utf-8") if title else None
+ mock_message.fields = lxmf_fields
+ self.queue.put(mock_message)
+ return
+
+ try:
+ dest_hash_bytes = bytes.fromhex(destination)
+ except ValueError:
+ RNS.log(f"Invalid destination hash format: {destination}", RNS.LOG_ERROR)
+ return
+
+ if len(dest_hash_bytes) != RNS.Reticulum.TRUNCATED_HASHLENGTH // 8:
+ RNS.log(f"Invalid destination hash length for {destination}", RNS.LOG_ERROR)
+ return
+
+ identity_instance = RNS.Identity.recall(dest_hash_bytes)
+ if identity_instance is None:
+ RNS.log(
+ f"Could not recall an Identity for {destination}. Requesting path...",
+ RNS.LOG_ERROR,
+ )
+ RNS.Transport.request_path(dest_hash_bytes)
+ RNS.log(
+ "Path requested. If the network knows a path, you will receive an announce shortly.",
+ RNS.LOG_INFO,
+ )
+ return
+
+ lxmf_destination_obj = RNS.Destination(
+ identity_instance,
+ RNS.Destination.OUT,
+ RNS.Destination.SINGLE,
+ "lxmf",
+ "delivery",
+ )
+
+ # Ensure message and title are bytes
+ message_bytes = message.encode("utf-8")
+ title_bytes = title.encode("utf-8") if title else None
+
+ # Determine delivery method based on retry count
+ attempts = self.delivery_attempts.get(destination, 0)
+ max_retries = self.config.direct_delivery_retries
+
+ # Check if we should prefer propagation
+ has_prop_node = (
+ self.config.propagation_node
+ or self.config.autopeer_propagation
+ or (
+ self.router.get_outbound_propagation_node() is not None
+ if self.router
+ else False
+ )
+ )
+
+ is_opportunistic = (
+ opportunistic
+ if opportunistic is not None
+ else self.config.opportunistic_sending
+ )
+
+ if attempts >= max_retries and self.config.propagation_fallback_enabled:
+ if not has_prop_node and not self.config.enable_propagation_node:
+ RNS.log(
+ f"Propagation fallback triggered for {destination}, but no propagation_node configured, "
+ "autopeer disabled, and bot is not a propagation node. Message will likely fail. "
+ "Configure propagation_node, enable autopeer_propagation, run as propagation node, "
+ "or disable propagation_fallback_enabled.",
+ RNS.LOG_ERROR,
+ )
+ desired_method = LXMessage.PROPAGATED
+ RNS.log(
+ f"Using propagation for {destination} after {attempts} failed direct attempts",
+ RNS.LOG_INFO,
+ )
+ else:
+ desired_method = LXMessage.DIRECT
+
+ # Use provided stamp_cost or fall back to config
+ final_stamp_cost = (
+ stamp_cost if stamp_cost is not None else self.config.stamp_cost
+ )
+
+ lxm = LXMessage(
+ lxmf_destination_obj,
+ self.local,
+ message_bytes,
+ title=title_bytes,
+ desired_method=desired_method,
+ fields=lxmf_fields,
+ stamp_cost=final_stamp_cost,
+ )
+
+ # Register callbacks to reset counter on success or track failure
+ def on_delivery_success(_message):
+ if destination in self.delivery_attempts:
+ self.delivery_attempts[destination] = 0
+ self._save_delivery_attempts()
+ RNS.log(
+ f"Delivery successful to {destination}, reset retry counter",
+ RNS.LOG_DEBUG,
+ )
+
+ def on_delivery_failure(_message):
+ current_attempts = self.delivery_attempts.get(destination, 0)
+ self.delivery_attempts[destination] = current_attempts + 1
+ self._save_delivery_attempts()
+
+ if current_attempts + 1 < max_retries:
+ RNS.log(
+ f"Delivery failed to {destination}, attempt {current_attempts + 1}/{max_retries}",
+ RNS.LOG_WARNING,
+ )
+ else:
+ RNS.log(
+ f"Delivery failed to {destination} after {current_attempts + 1} attempts",
+ RNS.LOG_ERROR,
+ )
+
+ lxm.register_delivery_callback(on_delivery_success)
+ lxm.register_failed_callback(on_delivery_failure)
+
+ # Sign the message (pass-through for LXMF's built-in signing)
+ lxm = sign_outgoing_message(self, lxm)
+
+ # Set propagation fallback if enabled
+ if (
+ desired_method == LXMessage.DIRECT
+ and (self.config.propagation_fallback_enabled or is_opportunistic)
+ and has_prop_node
+ ):
+ lxm.try_propagation_on_fail = True
+
+ self.queue.put(lxm)
+ self._persist_queue()
+ RNS.log(
+ f"Message queued for {destination} (method: {desired_method}, opportunistic: {is_opportunistic})",
+ RNS.LOG_DEBUG,
+ )
+
+ def _persist_queue(self):
+ """Persist the outgoing message queue to storage."""
+ if getattr(self.config, "message_persistence_enabled", False) is not True:
+ return
+
+ # Persist destination/content/title/fields/method; LXMessage is not trivially serializable.
+
+ queued_messages = []
+ for lxm in list(self.queue.queue):
+ try:
+ msg_data = {
+ "destination": RNS.hexrep(lxm.destination_hash, delimit=False),
+ "content": lxm.content.decode("utf-8")
+ if isinstance(lxm.content, bytes)
+ else lxm.content,
+ "title": lxm.title.decode("utf-8")
+ if isinstance(lxm.title, bytes)
+ else lxm.title,
+ "fields": lxm.fields,
+ "method": lxm.desired_method,
+ }
+ queued_messages.append(msg_data)
+ except Exception as e:
+ self.logger.error("Failed to serialize message for persistence: %s", e)
+
+ self.storage.set("persisted_queue", queued_messages)
+
+ def _load_persisted_queue(self):
+ """Load persisted messages back into the queue."""
+ if getattr(self.config, "message_persistence_enabled", False) is not True:
+ return
+
+ persisted = self.storage.get("persisted_queue", [])
+ if not persisted:
+ return
+
+ RNS.log(f"Restoring {len(persisted)} messages from persistence", RNS.LOG_INFO)
+ for msg_data in persisted:
+ try:
+ self.send(
+ msg_data["destination"],
+ msg_data["content"],
+ title=msg_data.get("title"),
+ lxmf_fields=msg_data.get("fields"),
+ )
+ except Exception as e:
+ self.logger.error("Failed to restore message from persistence: %s", e)
+
+ # Clear after loading to avoid duplicates if send() fails again
+ self.storage.set("persisted_queue", [])
+
+ def send_with_attachment(
+ self,
+ destination: str,
+ message: str,
+ attachment: Attachment,
+ title: str = "Reply",
+ stamp_cost: int | None = None,
+ opportunistic: bool | None = None,
+ ):
+ """Send a message with an attachment to a destination.
+
+ Args:
+ destination: The destination hash.
+ message: The message content.
+ attachment: The attachment to send.
+ title: The message title.
+ stamp_cost: Optional stamp cost override.
+ opportunistic: Whether to use opportunistic sending.
+
+ """
+ attachment_specific_fields = pack_attachment(attachment)
+ self.send(
+ destination,
+ message,
+ title=title,
+ lxmf_fields=attachment_specific_fields,
+ stamp_cost=stamp_cost,
+ opportunistic=opportunistic,
+ )
+
+ def run(self, delay=10):
+ """Run the bot"""
+ self.scheduler.start() # Start the scheduler
+ try:
+ while True:
+ # Process outgoing queue with a timeout to prevent hanging
+ while not self.queue.empty():
+ try:
+ # Non-blocking get with a small timeout for safety
+ lxm = self.queue.get(block=False)
+ if self.router:
+ self.router.handle_outbound(lxm)
+ except Exception:
+ break
+
+ time.sleep(delay)
+
+ except KeyboardInterrupt:
+ self.cleanup() # Call cleanup on KeyboardInterrupt
+
+ def received(self, function):
+ """Decorator for registering delivery callbacks.
+
+ Args:
+ function: The function to call when a message is delivered.
+
+ """
+ self.delivery_callbacks.append(function)
+ return function
+
+ def request_page(
+ self,
+ destination_hash: str,
+ page_path: str,
+ field_data: dict | None = None,
+ ) -> dict:
+ """Request a page from a destination.
+
+ Args:
+ destination_hash: The destination hash.
+ page_path: The path to the page.
+ field_data: Optional field data to send with the request.
+
+ Returns:
+ The response from the destination.
+
+ """
+ try:
+ dest_hash_bytes = bytes.fromhex(destination_hash)
+ return self.transport.request_page(dest_hash_bytes, page_path, field_data)
+ except Exception as e:
+ self.logger.error("Error requesting page: %s", str(e))
+ raise
+
+ def cleanup(self):
+ """Clean up resources."""
+ RNS.log("Cleaning up LXMFBot...", RNS.LOG_DEBUG)
+ self.transport.cleanup()
+ self.thread_pool.shutdown(wait=False)
+ self.scheduler.stop()
+ if hasattr(self, "router") and self.router:
+ try:
+ self.router.exit_handler()
+ except Exception: # noqa: S110
+ pass
+
+ # Ensure Reticulum exits cleanly
+ if not self.config.test_mode:
+ try:
+ RNS.Reticulum.exit_handler()
+ except Exception: # noqa: S110
+ pass
+ RNS.log("LXMFBot cleanup complete", RNS.LOG_DEBUG)
+
+ def get_propagation_node_status(self):
+ """Get information about configured and discovered propagation nodes.
+
+ Returns:
+ dict: Dictionary with propagation node configuration and status.
+
+ """
+ if self.config.test_mode:
+ return {
+ "test_mode": True,
+ "error": "Not available in test mode",
+ }
+
+ status = {
+ "manual_node": self.config.propagation_node,
+ "autopeer_enabled": self.config.autopeer_propagation,
+ "autopeer_maxdepth": self.config.autopeer_maxdepth,
+ "is_propagation_node": self.config.enable_propagation_node,
+ "current_outbound_node": None,
+ "discovered_peers": [],
+ }
+
+ current_node = self.router.get_outbound_propagation_node()
+ if current_node:
+ status["current_outbound_node"] = RNS.hexrep(current_node, delimit=False)
+
+ if hasattr(self.router, "peers") and self.router.peers:
+ status["discovered_peers"] = [
+ {
+ "hash": RNS.hexrep(peer_hash, delimit=False),
+ "hops": RNS.Transport.hops_to(peer_hash),
+ }
+ for peer_hash in self.router.peers.keys()
+ ]
+
+ return status
+
+ def set_propagation_node(self, node_hash: str):
+ """Manually set the outbound propagation node.
+
+ Args:
+ node_hash: The destination hash of the propagation node.
+
+ """
+ if self.config.test_mode:
+ RNS.log("Cannot set propagation node in test mode", RNS.LOG_WARNING)
+ return
+
+ try:
+ propagation_node_bytes = bytes.fromhex(node_hash)
+ self.router.set_outbound_propagation_node(propagation_node_bytes)
+ self.config.propagation_node = node_hash
+ RNS.log(
+ f"Set outbound propagation node to: {RNS.prettyhexrep(propagation_node_bytes)}",
+ RNS.LOG_INFO,
+ )
+ except ValueError:
+ RNS.log(
+ f"Invalid propagation node hash format: {node_hash}",
+ RNS.LOG_ERROR,
+ )
+ raise
+
+ def set_message_storage_limit(self, megabytes: float):
+ """Set the message storage limit for propagation node mode.
+
+ Args:
+ megabytes: Storage limit in megabytes. Set to 0 for unlimited.
+
+ """
+ if self.config.test_mode:
+ RNS.log("Cannot set storage limit in test mode", RNS.LOG_WARNING)
+ return
+
+ if not self.config.enable_propagation_node:
+ RNS.log(
+ "Storage limit only applies when running as a propagation node",
+ RNS.LOG_WARNING,
+ )
+ return
+
+ try:
+ if megabytes <= 0:
+ self.router.set_message_storage_limit()
+ self.config.message_storage_limit_mb = 0
+ RNS.log("Removed message storage limit (unlimited)", RNS.LOG_INFO)
+ else:
+ self.router.set_message_storage_limit(megabytes=megabytes)
+ self.config.message_storage_limit_mb = megabytes
+ RNS.log(
+ f"Set message storage limit to {megabytes} MB",
+ RNS.LOG_INFO,
+ )
+ except Exception as e:
+ RNS.log(
+ f"Failed to set message storage limit: {e}",
+ RNS.LOG_ERROR,
+ )
+ raise
+
+ def get_propagation_storage_stats(self):
+ """Get storage statistics for propagation node mode.
+
+ Returns:
+ dict: Dictionary with storage statistics or None if not a propagation node.
+
+ """
+ if self.config.test_mode:
+ return {"test_mode": True, "error": "Not available in test mode"}
+
+ if not self.config.enable_propagation_node:
+ return {
+ "is_propagation_node": False,
+ "error": "Not running as propagation node",
+ }
+
+ try:
+ storage_size = self.router.message_storage_size()
+ storage_limit = self.router.message_storage_limit
+
+ stats = {
+ "is_propagation_node": True,
+ "storage_size_bytes": storage_size,
+ "storage_size_mb": storage_size / (1000 * 1000) if storage_size else 0,
+ "storage_limit_bytes": storage_limit,
+ "storage_limit_mb": storage_limit / (1000 * 1000)
+ if storage_limit
+ else None,
+ "utilization_percent": (storage_size / storage_limit * 100)
+ if (storage_limit and storage_size)
+ else 0,
+ "message_count": len(self.router.propagation_entries)
+ if hasattr(self.router, "propagation_entries")
+ else 0,
+ }
+
+ return stats
+ except Exception as e:
+ return {"error": f"Failed to get stats: {e}"} # Stop the scheduler
+
+ def intent(self, name: str, examples: list[str]):
+ """Decorator for registering intent handlers.
+
+ Args:
+ name: The name of the intent.
+ examples: A list of example phrases for this intent.
+
+ """
+
+ def decorator(func):
+ self.nlp.add_intent(name, examples)
+ self.intents[name] = func
+ return func
+
+ return decorator
+
+ def request_link(
+ self,
+ destination_hash: str,
+ callback: Callable = None,
+ app_name: str = "lxmf",
+ *aspects: str,
+ ):
+ """Request an RNS link to a destination.
+
+ Args:
+ destination_hash: The destination hash string.
+ callback: Optional callback when link is established.
+ app_name: The app name for the destination (default: "lxmf").
+ *aspects: Additional aspects for the destination (default: "delivery" if none provided).
+
+ """
+ if not self.config.link_support_enabled:
+ raise Exception("Link support is disabled in config")
+
+ if not aspects:
+ aspects = ("delivery",)
+
+ dest_bytes = bytes.fromhex(destination_hash)
+ identity = RNS.Identity.recall(dest_bytes)
+ if not identity:
+ RNS.Transport.request_path(dest_bytes)
+ raise Exception(
+ f"Identity for {destination_hash} not known, requesting path",
+ )
+
+ dest = RNS.Destination(
+ identity,
+ RNS.Destination.OUT,
+ RNS.Destination.SINGLE,
+ app_name,
+ *aspects,
+ )
+ link = RNS.Link(dest)
+
+ if callback:
+
+ def _link_established(link):
+ callback(link)
+
+ link.set_link_established_callback(_link_established)
+
+ self.links[destination_hash] = link
+ return link
+
+ def on_link(self, callback: Callable):
+ """Register a handler for incoming links."""
+ self.link_handlers.append(callback)
+
+ def _link_established(self, link):
+ """Handle an established RNS link."""
+ sender = RNS.hexrep(link.destination.hash, delimit=False)
+ self.links[sender] = link
+ for handler in self.link_handlers:
+ try:
+ handler(link)
+ except Exception as e:
+ self.logger.error("Error in link handler: %s", e)
+
+ def on_first_message(self):
+ """Decorator for registering first message handlers"""
+
+ def decorator(func):
+ """Registers a function to be called on the first message from a sender."""
+ self.first_message_handlers.append(func)
+ return func
+
+ return decorator
+
+ def on_message(self):
+ """Decorator for registering message handlers"""
+
+ def decorator(func):
+ """Registers a function to be called on every message."""
+ self.message_handlers.append(func)
+ return func
+
+ return decorator
+
+ def validate(self) -> str:
+ """Run validation checks and return formatted results."""
+ results = validate_bot(self)
+ return format_validation_results(results)

diff --git a/vendor/lxmfy/lxmfy/events.py b/vendor/lxmfy/lxmfy/events.py
new file mode 100644
index 00000000..8572d795
--- /dev/null
+++ b/vendor/lxmfy/lxmfy/events.py
@@ -0,0 +1,176 @@
+"""Event system module for LXMFy.
+
+This module provides a comprehensive event handling system including:
+- Custom event creation and dispatching
+- Event middleware support
+- Event logging and monitoring
+"""
+
+import logging
+from collections.abc import Callable
+from dataclasses import dataclass, field
+from datetime import datetime
+from enum import Enum
+
+logger = logging.getLogger(__name__)
+
+
+class EventPriority(Enum):
+ """Enumeration of event priority levels.
+
+ Members:
+ HIGHEST: Highest priority.
+ HIGH: High priority.
+ NORMAL: Normal priority.
+ LOW: Low priority.
+ """
+
+ HIGHEST = 3
+ HIGH = 2
+ NORMAL = 1
+ LOW = 0
+
+
+@dataclass(frozen=True)
+class Event:
+ """Data class representing an event.
+
+ Attributes:
+ name (str): The name of the event.
+ data (dict): A dictionary containing event-specific data.
+ cancelled (bool): A flag indicating whether the event has been cancelled.
+
+ """
+
+ name: str
+ data: dict = field(default_factory=dict)
+ cancelled: bool = False
+
+ def __hash__(self):
+ """Returns the hash value of the event based on its name.
+
+ Returns:
+ int: Hash value of the event name.
+
+ """
+ return hash(self.name)
+
+ def __eq__(self, other):
+ """Compares this event to another object for equality.
+
+ Args:
+ other (Any): The object to compare to.
+
+ Returns:
+ bool: True if the other object is an Event instance and has the same name, False otherwise.
+
+ """
+ if not isinstance(other, Event):
+ return False
+ return self.name == other.name
+
+ def cancel(self):
+ """Cancels the event, preventing further processing."""
+ object.__setattr__(self, "cancelled", True)
+
+
+@dataclass
+class EventHandler:
+ """Data class representing an event handler.
+
+ Attributes:
+ callback (Callable): The function to be called when the event is dispatched.
+ priority (EventPriority): The priority of the event handler.
+
+ """
+
+ callback: Callable
+ priority: EventPriority
+
+
+class EventManager:
+ """Manages event registration, dispatching, and logging."""
+
+ def __init__(self, storage):
+ """Initializes the EventManager.
+
+ Args:
+ storage: The storage object used for logging events.
+
+ """
+ self.storage = storage
+ self.handlers = {}
+ self.logger = logging.getLogger(__name__)
+
+ def on(self, event_name: str, priority: EventPriority = EventPriority.NORMAL):
+ """Registers an event handler for a specific event.
+
+ Args:
+ event_name (str): The name of the event to handle.
+ priority (EventPriority): The priority of the event handler (default: EventPriority.NORMAL).
+
+ Returns:
+ Callable: A decorator that registers the decorated function as an event handler.
+
+ """
+
+ def decorator(func):
+ """Registers the decorated function as an event handler."""
+ if event_name not in self.handlers:
+ self.handlers[event_name] = []
+ self.handlers[event_name].append((priority, func))
+ self.handlers[event_name].sort(key=lambda x: x[0].value, reverse=True)
+ return func
+
+ return decorator
+
+ def use(self, middleware: Callable):
+ """Adds middleware to the event pipeline.
+
+ Args:
+ middleware (Callable): The middleware function to add.
+
+ """
+
+ def dispatch(self, event: Event):
+ """Dispatches an event to all registered handlers.
+
+ Args:
+ event (Event): The event to dispatch.
+
+ """
+ try:
+ if event.name in self.handlers:
+ for _priority, handler in self.handlers[event.name]:
+ try:
+ handler(event)
+ if event.cancelled:
+ break
+ except Exception as e:
+ self.logger.error(
+ "Error in event handler %s: %s",
+ handler.__name__,
+ str(e),
+ )
+ except Exception as e:
+ self.logger.error("Error dispatching event: %s", str(e))
+
+ def _log_event(self, event: Event):
+ """Logs an event to storage.
+
+ Args:
+ event (Event): The event to log.
+
+ """
+ try:
+ events = self.storage.get("events:log", [])
+ events.append(
+ {
+ "name": event.name,
+ "data": event.data,
+ "timestamp": datetime.now().isoformat(),
+ },
+ )
+ self.storage.set("events:log", events[-1000:])
+ except Exception as e:
+ self.logger.error("Error logging event: %s", str(e))

diff --git a/vendor/lxmfy/lxmfy/help.py b/vendor/lxmfy/lxmfy/help.py
new file mode 100644
index 00000000..4814afb9
--- /dev/null
+++ b/vendor/lxmfy/lxmfy/help.py
@@ -0,0 +1,163 @@
+"""Help command system for LXMFy."""
+
+from dataclasses import dataclass
+
+from .permissions import DefaultPerms
+
+
+@dataclass
+class HelpFormatter:
+ """Default help formatter for commands."""
+
+ @staticmethod
+ def format_command(command) -> str:
+ """Format a single command's help.
+
+ Args:
+ command: The command object to format.
+
+ Returns:
+ A formatted string containing the command's help information.
+
+ """
+ help_text = [
+ f"**Command**: `{command.name}`",
+ f"**Description**: {command.help.description}",
+ ]
+
+ if command.help.aliases:
+ help_text.append(
+ f"**Aliases**: {', '.join(f'`{a}`' for a in command.help.aliases)}",
+ )
+
+ if command.help.usage:
+ help_text.append(f"**Usage**: `{command.help.usage}`")
+
+ if command.help.examples:
+ help_text.append("**Examples**:")
+ help_text.extend(f" - `{ex}`" for ex in command.help.examples)
+
+ if command.permissions != DefaultPerms.USE_COMMANDS:
+ perms = [
+ perm.name
+ for perm in DefaultPerms
+ if (perm.value & command.permissions.value)
+ and perm != DefaultPerms.NONE
+ ]
+ if perms:
+ help_text.append("**Required Permissions**:")
+ help_text.extend(f" - {p}" for p in perms)
+
+ if command.admin_only:
+ help_text.append("*(Admin only)*")
+
+ return "\n".join(help_text)
+
+ @staticmethod
+ def format_category(category: str, commands: list) -> str:
+ """Format a category of commands.
+
+ Args:
+ category (str): The name of the category.
+ commands (list): A list of command objects in the category.
+
+ Returns:
+ str: A formatted string containing the category's help information.
+
+ """
+ # Deduplicate commands (aliases point to same command)
+ unique_cmds = {}
+ for cmd in commands:
+ unique_cmds[cmd.name] = cmd
+
+ help_text = [f"\n**{category}**"]
+ help_text.extend(
+ f" * `{cmd.name}`: {cmd.help.description}"
+ for cmd in sorted(unique_cmds.values(), key=lambda x: x.name)
+ )
+ return "\n".join(help_text)
+
+ @staticmethod
+ def format_all_commands(categories: dict[str, list]) -> str:
+ """Format the complete help listing.
+
+ Args:
+ categories (dict): A dictionary where keys are category names and values are lists of command objects.
+
+ Returns:
+ str: A formatted string containing help information for all commands.
+
+ """
+ help_text = ["**Bot Help Menu**", "Use `help <command>` for more details."]
+
+ help_text.extend(
+ HelpFormatter.format_category(category, categories[category])
+ for category in sorted(categories.keys())
+ )
+
+ return "\n".join(help_text)
+
+
+class HelpSystem:
+ """A system for providing help information about available commands."""
+
+ def __init__(self, bot, formatter=None):
+ """Initialize the HelpSystem.
+
+ Args:
+ bot: The bot instance.
+ formatter: An optional help formatter. Defaults to HelpFormatter.
+
+ """
+ self.bot = bot
+ self.formatter = formatter or HelpFormatter()
+
+ @bot.command(name="help", description="Show help for commands")
+ def help_command(ctx):
+ """Handle the 'help' command.
+
+ Args:
+ ctx: The command context.
+
+ """
+ args = ctx.args
+ if not args:
+ categories = self._get_categorized_commands(ctx.is_admin)
+ response = self.formatter.format_all_commands(categories)
+ ctx.reply(response)
+ return
+
+ command_name = args[0]
+ if command_name in self.bot.commands:
+ command = self.bot.commands[command_name]
+ if command.admin_only and not ctx.is_admin:
+ ctx.reply("This command is for administrators only.")
+ return
+ response = self.formatter.format_command(command)
+ ctx.reply(response)
+ return
+ ctx.reply(f"Command '{command_name}' not found.")
+ return
+
+ def _get_categorized_commands(self, is_admin: bool) -> dict[str, list]:
+ """Group commands by category.
+
+ Args:
+ is_admin (bool): Whether the user is an admin.
+
+ Returns:
+ dict[str, list]: A dictionary where keys are category names and values are lists of command objects.
+
+ """
+ categories = {}
+
+ for cmd in self.bot.commands.values():
+ if cmd.admin_only and not is_admin:
+ continue
+
+ category = cmd.help.category or "General"
+ if category not in categories:
+ categories[category] = []
+ categories[category].append(cmd)
+
+ return categories

diff --git a/vendor/lxmfy/lxmfy/middleware.py b/vendor/lxmfy/lxmfy/middleware.py
new file mode 100644
index 00000000..ba0133c2
--- /dev/null
+++ b/vendor/lxmfy/lxmfy/middleware.py
@@ -0,0 +1,114 @@
+"""Middleware system for LXMFy.
+
+This module provides a flexible middleware system for processing messages
+and events, allowing users to add custom processing logic to the bot's
+message handling pipeline.
+"""
+
+import logging
+from collections.abc import Callable
+from dataclasses import dataclass, field
+from enum import Enum
+from typing import Any
+
+logger = logging.getLogger(__name__)
+
+
+class MiddlewareType(Enum):
+ """Types of middleware execution points"""
+
+ PRE_COMMAND = "pre_command"
+ POST_COMMAND = "post_command"
+ PRE_EVENT = "pre_event"
+ POST_EVENT = "post_event"
+ REQUEST = "request"
+ RESPONSE = "response"
+
+
+@dataclass
+class MiddlewareContext:
+ """Context passed through middleware chain"""
+
+ type: MiddlewareType
+ data: Any
+ metadata: dict = field(default_factory=dict)
+ cancelled: bool = False
+
+ def cancel(self):
+ """Cancel middleware processing"""
+ self.cancelled = True
+
+
+class MessageTracker:
+ """Tracks processed messages to prevent duplicates"""
+
+ def __init__(self, max_size=1000):
+ self.processed_set = set()
+ self.processed_list = []
+ self.max_size = max_size
+
+ def is_processed(self, msg_hash: str) -> bool:
+ """Check if message was already processed"""
+ if msg_hash in self.processed_set:
+ return True
+
+ self.processed_set.add(msg_hash)
+ self.processed_list.append(msg_hash)
+
+ if len(self.processed_list) > self.max_size:
+ oldest = self.processed_list.pop(0)
+ self.processed_set.discard(oldest)
+
+ return False
+
+
+class MiddlewareManager:
+ """Manages middleware registration and execution"""
+
+ def __init__(self):
+ self.middleware: dict[MiddlewareType, list[Callable]] = {
+ t: [] for t in MiddlewareType
+ }
+ self.message_tracker = MessageTracker()
+ self.logger = logging.getLogger(__name__)
+
+ def register(self, middleware_type: MiddlewareType, func: Callable = None):
+ """Register a middleware function"""
+ if func is None:
+ # Decorator usage: @middleware.register(MiddlewareType.PRE_COMMAND)
+ def decorator(f):
+ self.middleware[middleware_type].append(f)
+ return f
+
+ return decorator
+ # Direct usage: middleware.register(MiddlewareType.PRE_COMMAND, func)
+ self.middleware[middleware_type].append(func)
+ return func
+
+ def remove(self, middleware_type: MiddlewareType, func: Callable):
+ """Remove a middleware function"""
+ if func in self.middleware[middleware_type]:
+ self.middleware[middleware_type].remove(func)
+
+ def execute(self, mw_type: MiddlewareType, data: Any) -> Any:
+ """Execute middleware chain for given type"""
+ try:
+ # If data is already a MiddlewareContext, use it directly
+ if isinstance(data, MiddlewareContext):
+ ctx = data
+ else:
+ ctx = MiddlewareContext(mw_type, data)
+
+ for mw in self.middleware.get(mw_type, []):
+ try:
+ mw(ctx)
+ if ctx.cancelled:
+ break
+ except Exception as e:
+ self.logger.error("Error in middleware %s: %s", mw.__name__, str(e))
+
+ return None if ctx.cancelled else ctx.data
+
+ except Exception as e:
+ self.logger.error("Error executing middleware chain: %s", str(e))
+ return data

diff --git a/vendor/lxmfy/lxmfy/moderation.py b/vendor/lxmfy/lxmfy/moderation.py
new file mode 100644
index 00000000..3cee7f83
--- /dev/null
+++ b/vendor/lxmfy/lxmfy/moderation.py
@@ -0,0 +1,145 @@
+"""Spam protection module for LXMFy.
+
+This module provides spam protection functionality for LXMFy bots,
+including rate limiting, warning system, and user banning capabilities.
+"""
+
+from collections import defaultdict
+from dataclasses import dataclass
+from time import time
+
+from .permissions import DefaultPerms
+
+
+@dataclass
+class SpamConfig:
+ """Configuration settings for spam protection."""
+
+ rate_limit: int = 5 # Maximum messages per cooldown period
+ cooldown: int = 60 # Cooldown period in seconds
+ max_warnings: int = 3 # Maximum warnings before ban
+ warning_timeout: int = 300 # Time before warnings reset
+
+
+class SpamProtection:
+ """Spam protection system for LXMF bots.
+
+ This class manages message rate limiting, user warnings, and bans to prevent
+ spam abuse of the bot. It persists data across bot restarts using the provided
+ storage system.
+
+ Attributes:
+ storage: Storage backend for persisting spam protection data
+ message_counts: Dictionary tracking message timestamps per user
+ warnings: Dictionary tracking warning counts per user
+ banned_users: Set of banned user hashes
+ warning_times: Dictionary tracking last warning time per user
+
+ """
+
+ def __init__(self, storage, bot, **kwargs):
+ """Initialize spam protection with the given configuration.
+
+ Args:
+ storage: Storage backend for persisting data
+ bot: Reference to the bot instance
+ **kwargs: Override default spam configuration settings
+
+ """
+ self.storage = storage
+ self.bot = bot
+ self.config = SpamConfig(**kwargs)
+ self.message_counts = defaultdict(list)
+ self.warnings = defaultdict(int)
+ self.banned_users = set()
+ self.warning_times = defaultdict(float)
+ self.load_data()
+
+ def load_data(self):
+ """Load spam protection data from storage."""
+ stored_counts = self.storage.get("spam:message_counts", {})
+ self.message_counts = defaultdict(list, stored_counts)
+ stored_warnings = self.storage.get("spam:warnings", {})
+ self.warnings = defaultdict(int, stored_warnings)
+ self.banned_users = set(self.storage.get("spam:banned_users", []))
+ stored_times = self.storage.get("spam:warning_times", {})
+ self.warning_times = defaultdict(float, stored_times)
+
+ def save_data(self):
+ """Save current spam protection data to storage."""
+ self.storage.set("spam:message_counts", dict(self.message_counts))
+ self.storage.set("spam:warnings", dict(self.warnings))
+ self.storage.set("spam:banned_users", list(self.banned_users))
+ self.storage.set("spam:warning_times", dict(self.warning_times))
+
+ def check_spam(self, sender) -> tuple[bool, str]:
+ """Check if a message from the sender should be allowed.
+
+ Args:
+ sender: Hash of the message sender
+
+ Returns:
+ Tuple[bool, str]: (allowed, message) where allowed indicates if the message
+ should be processed and message contains any warning/ban notification
+
+ """
+ # Check if user has bypass permission
+ if self.bot.permissions.has_permission(sender, DefaultPerms.BYPASS_SPAM):
+ return True, None
+
+ if sender in self.banned_users:
+ return False, "You are banned from using this bot."
+
+ current_time = time()
+
+ # Clean old messages
+ self.message_counts[sender] = [
+ t
+ for t in self.message_counts[sender]
+ if current_time - t <= self.config.cooldown
+ ]
+
+ # Check rate limit
+ if len(self.message_counts[sender]) >= self.config.rate_limit:
+ self.warnings[sender] += 1
+ self.warning_times[sender] = current_time
+
+ if self.warnings[sender] >= self.config.max_warnings:
+ self.banned_users.add(sender)
+ self.save_data()
+ return False, "You have been banned for spamming."
+
+ self.save_data()
+ return (
+ False,
+ f"Rate limit exceeded. Warning {self.warnings[sender]}/{self.config.max_warnings}",
+ )
+
+ # Add new message timestamp
+ self.message_counts[sender].append(current_time)
+
+ # Reset warnings if warning_timeout has passed
+ if (
+ current_time - self.warning_times.get(sender, 0)
+ ) > self.config.warning_timeout:
+ self.warnings[sender] = 0
+
+ self.save_data()
+ return True, None
+
+ def unban(self, sender) -> bool:
+ """Remove a user from the ban list.
+
+ Args:
+ sender: Hash of the user to unban
+
+ Returns:
+ bool: True if the user was unbanned, False if they weren't banned
+
+ """
+ if sender in self.banned_users:
+ self.banned_users.remove(sender)
+ self.warnings[sender] = 0
+ self.save_data()
+ return True
+ return False

diff --git a/vendor/lxmfy/lxmfy/nlp.py b/vendor/lxmfy/lxmfy/nlp.py
new file mode 100644
index 00000000..964e980a
--- /dev/null
+++ b/vendor/lxmfy/lxmfy/nlp.py
@@ -0,0 +1,255 @@
+"""NLP module for LXMFy.
+
+This module provides lightweight, local intent classification using mathematical
+vector embeddings (TF-IDF and Cosine Similarity).
+"""
+
+import math
+import re
+from collections import Counter
+
+
+class IntentClassifier:
+ """Lightweight intent classifier using TF-IDF and Cosine Similarity."""
+
+ def __init__(self, threshold: float = 0.5, use_char_ngrams: bool = True):
+ """Initialize the IntentClassifier.
+
+ Args:
+ threshold: Minimum similarity score to consider a match.
+ use_char_ngrams: Whether to use character n-grams for typo resilience.
+
+ """
+ self.intents = {} # {intent_name: [example_vectors]}
+ self.vocabulary = set()
+ self.idf = {}
+ self.threshold = threshold
+ self.use_char_ngrams = use_char_ngrams
+ self._processed_examples = {} # {intent_name: [processed_vectors]}
+ self._initialize_base_idf()
+
+ def _initialize_base_idf(self):
+ """Initialize with a small set of common English words to provide baseline intelligence."""
+ # Baseline IDF values for very common words to help with weighting
+ # These are used as a fallback when training data is sparse.
+ self.base_idf = {
+ "the": 0.1,
+ "be": 0.2,
+ "to": 0.2,
+ "of": 0.2,
+ "and": 0.2,
+ "a": 0.2,
+ "in": 0.3,
+ "that": 0.3,
+ "have": 0.3,
+ "i": 0.3,
+ "it": 0.3,
+ "for": 0.3,
+ "not": 0.4,
+ "on": 0.4,
+ "with": 0.4,
+ "he": 0.4,
+ "as": 0.4,
+ "you": 0.4,
+ "do": 0.4,
+ "at": 0.4,
+ }
+ for word in self.base_idf:
+ self.vocabulary.add(word)
+
+ def add_intent(self, name: str, examples: list[str], train: bool = True):
+ """Add an intent with training examples.
+
+ Args:
+ name: The name of the intent.
+ examples: A list of example phrases for this intent.
+ train: Whether to retrain the model immediately.
+
+ """
+ self.intents[name] = examples
+ if train:
+ self._train()
+
+ def train(self):
+ """Manually trigger model training."""
+ self._train()
+
+ @staticmethod
+ def _tokenize(text: str) -> list[str]:
+ """Tokenize and clean text."""
+ if not text:
+ return []
+ # Limit text length and use a simple regex to avoid any potential backtracking
+ return re.findall(r"[a-z0-9]+", text.lower()[:1000])
+
+ @staticmethod
+ def _get_char_ngrams(text: str, n: int = 3) -> list[str]:
+ """Generate character n-grams for typo resilience."""
+ if not text:
+ return []
+ text = f"^{text.strip()}$"
+ if len(text) < n:
+ return []
+ return [text[i : i + n] for i in range(len(text) - n + 1)]
+
+ def _get_features(self, text: str) -> list[str]:
+ """Extract features (tokens and optionally n-grams) from text."""
+ tokens = self._tokenize(text)
+ if not tokens:
+ return []
+
+ features = list(tokens)
+ if self.use_char_ngrams:
+ seen_words = set()
+ for word in tokens:
+ if word not in seen_words and len(word) > 2:
+ # Use both bigrams and trigrams for better typo resilience
+ features.extend(self._get_char_ngrams(word, n=2))
+ features.extend(self._get_char_ngrams(word, n=3))
+ seen_words.add(word)
+ return features
+
+ def _train(self):
+ """Calculate IDF and vectorize examples efficiently."""
+ all_docs_features = []
+ for examples in self.intents.values():
+ for ex in examples:
+ feats = self._get_features(ex)
+ if feats:
+ all_docs_features.append(set(feats))
+
+ num_docs = len(all_docs_features)
+ if num_docs == 0:
+ return
+
+ # Pre-calculate document frequencies
+ df_counts = Counter()
+ for doc_set in all_docs_features:
+ df_counts.update(doc_set)
+
+ # Build IDF, incorporating base words if they are not already weighted
+ self.idf = dict(self.base_idf)
+ for feature, count in df_counts.items():
+ # Standard IDF: log(N/df)
+ # We add 1 to denominator to avoid division by zero (though count is >= 1)
+ # and use log(1 + N/df) for smoother weighting
+ self.idf[feature] = math.log(1 + (num_docs / count))
+
+ self._processed_examples = {}
+ for name, examples in self.intents.items():
+ processed_for_intent = []
+ for ex in examples:
+ vector = self._vectorize(self._get_features(ex))
+ if vector:
+ magnitude = math.sqrt(sum(v**2 for v in vector.values()))
+ if magnitude > 0:
+ processed_for_intent.append((vector, magnitude))
+ self._processed_examples[name] = processed_for_intent
+
+ def _vectorize(self, tokens: list[str]) -> dict[str, float]:
+ """Convert tokens to a TF-IDF vector (dictionary representation)."""
+ if not tokens:
+ return {}
+
+ counts = Counter(tokens)
+ vector = {}
+ total_tokens = len(tokens)
+
+ for token, count in counts.items():
+ if token in self.idf:
+ tf = count / total_tokens
+ vector[token] = tf * self.idf[token]
+ return vector
+
+ @staticmethod
+ def _cosine_similarity(
+ v1: dict[str, float],
+ mag1: float,
+ v2: dict[str, float],
+ mag2: float,
+ ) -> float:
+ """Calculate cosine similarity between two sparse vectors."""
+ # Intersection of keys for sparse dot product
+ if len(v1) < len(v2):
+ intersection = [x for x in v1 if x in v2]
+ else:
+ intersection = [x for x in v2 if x in v1]
+
+ if not intersection:
+ return 0.0
+
+ numerator = sum(v1[x] * v2[x] for x in intersection)
+ denominator = mag1 * mag2
+
+ if denominator <= 0:
+ return 0.0
+ return numerator / denominator
+
+ def export_model(self) -> dict:
+ """Export the trained model data for persistence.
+
+ Returns:
+ A dictionary containing the IDF and processed example vectors.
+
+ """
+ return {
+ "idf": self.idf,
+ "processed_examples": self._processed_examples,
+ "intents": self.intents,
+ "vocabulary": list(self.vocabulary),
+ "threshold": self.threshold,
+ "use_char_ngrams": self.use_char_ngrams,
+ }
+
+ def import_model(self, model_data: dict):
+ """Import a previously exported model.
+
+ Args:
+ model_data: The dictionary returned by export_model.
+
+ """
+ self.idf = model_data.get("idf", {})
+ self._processed_examples = model_data.get("processed_examples", {})
+ self.intents = model_data.get("intents", {})
+ self.vocabulary = set(model_data.get("vocabulary", []))
+ self.threshold = model_data.get("threshold", self.threshold)
+ self.use_char_ngrams = model_data.get("use_char_ngrams", self.use_char_ngrams)
+
+ def predict(self, text: str) -> tuple[str | None, float]:
+ """Predict the intent of a given text.
+
+ Returns:
+ A tuple of (intent_name, confidence_score).
+
+ """
+ tokens = self._get_features(text)
+ if not tokens:
+ return None, 0.0
+
+ query_vector = self._vectorize(tokens)
+ if not query_vector:
+ return None, 0.0
+
+ # Pre-calculate query magnitude once
+ query_magnitude = math.sqrt(sum(v**2 for v in query_vector.values()))
+ if not query_magnitude:
+ return None, 0.0
+
+ best_intent = None
+ max_score = 0.0
+
+ for name, examples in self._processed_examples.items():
+ for example_vector, magnitude in examples:
+ score = self._cosine_similarity(
+ query_vector,
+ query_magnitude,
+ example_vector,
+ magnitude,
+ )
+ if score > max_score:
+ max_score = score
+ best_intent = name
+
+ if max_score >= self.threshold:
+ return best_intent, max_score
+ return None, max_score

diff --git a/vendor/lxmfy/lxmfy/permissions.py b/vendor/lxmfy/lxmfy/permissions.py
new file mode 100644
index 00000000..7ca71a10
--- /dev/null
+++ b/vendor/lxmfy/lxmfy/permissions.py
@@ -0,0 +1,202 @@
+"""Permissions system for LXMFy."""
+
+from dataclasses import dataclass, field
+from enum import Flag, auto
+from typing import Any
+
+
+class BasePermission(Flag):
+ """Base permission flags"""
+
+ NONE = 0
+ READ = auto()
+ WRITE = auto()
+ EXECUTE = auto()
+ MANAGE = auto()
+ ALL = READ | WRITE | EXECUTE | MANAGE
+
+
+class DefaultPerms(Flag):
+ """Default permission set"""
+
+ NONE = 0
+ # Basic permissions
+ USE_BOT = auto()
+ SEND_MESSAGES = auto()
+ USE_COMMANDS = auto()
+
+ # Elevated permissions
+ MANAGE_MESSAGES = auto()
+ MANAGE_COMMANDS = auto()
+ MANAGE_USERS = auto()
+
+ # Special permissions
+ BYPASS_RATELIMIT = auto()
+ BYPASS_SPAM = auto()
+ VIEW_ADMIN_COMMANDS = auto()
+
+ # Event system permissions
+ VIEW_EVENTS = auto()
+ MANAGE_EVENTS = auto()
+ BYPASS_EVENT_CHECKS = auto()
+
+ # Combined permissions
+ ALL = (
+ USE_BOT
+ | SEND_MESSAGES
+ | USE_COMMANDS
+ | MANAGE_MESSAGES
+ | MANAGE_COMMANDS
+ | MANAGE_USERS
+ | BYPASS_RATELIMIT
+ | BYPASS_SPAM
+ | VIEW_ADMIN_COMMANDS
+ | VIEW_EVENTS
+ | MANAGE_EVENTS
+ | BYPASS_EVENT_CHECKS
+ )
+
+
+@dataclass
+class Role:
+ """Role definition with permissions"""
+
+ name: str
+ permissions: DefaultPerms
+ priority: int = 0
+ description: str | None = None
+
+
+@dataclass
+class PermissionManager:
+ """Manages permissions, roles, and user assignments"""
+
+ storage: Any
+ enabled: bool = False
+ default_role: Role = field(
+ default_factory=lambda: Role(
+ "user",
+ DefaultPerms.USE_BOT
+ | DefaultPerms.SEND_MESSAGES
+ | DefaultPerms.USE_COMMANDS,
+ ),
+ )
+ admin_role: Role = field(
+ default_factory=lambda: Role("admin", DefaultPerms.ALL, priority=100),
+ )
+
+ def __post_init__(self):
+ self.roles: dict[str, Role] = {
+ "user": self.default_role,
+ "admin": self.admin_role,
+ }
+ self.user_roles: dict[str, set[str]] = {}
+ self.load_data()
+
+ def load_data(self):
+ """Load permission data from storage"""
+ stored_roles = self.storage.get("permissions:roles", {})
+ stored_user_roles = self.storage.get("permissions:user_roles", {})
+
+ # Convert stored roles back to Role objects
+ for role_name, role_data in stored_roles.items():
+ if role_name not in ["user", "admin"]: # Don't override default/admin
+ self.roles[role_name] = Role(
+ name=role_data["name"],
+ permissions=DefaultPerms(role_data["permissions"]),
+ priority=role_data["priority"],
+ description=role_data.get("description"),
+ )
+
+ self.user_roles = {
+ user: set(roles) for user, roles in stored_user_roles.items()
+ }
+
+ def save_data(self):
+ """Save permission data to storage"""
+ # Convert roles to serializable format
+ roles_data = {
+ name: {
+ "name": role.name,
+ "permissions": role.permissions.value,
+ "priority": role.priority,
+ "description": role.description,
+ }
+ for name, role in self.roles.items()
+ }
+
+ self.storage.set("permissions:roles", roles_data)
+ self.storage.set(
+ "permissions:user_roles",
+ {user: list(roles) for user, roles in self.user_roles.items()},
+ )
+
+ def create_role(
+ self,
+ name: str,
+ permissions: DefaultPerms,
+ priority: int = 0,
+ description: str | None = None,
+ ) -> Role:
+ """Create a new role"""
+ if name in self.roles:
+ raise ValueError(f"Role {name} already exists")
+
+ role = Role(name, permissions, priority, description)
+ self.roles[name] = role
+ self.save_data()
+ return role
+
+ def delete_role(self, name: str) -> bool:
+ """Delete a role"""
+ if name in ["user", "admin"]:
+ raise ValueError("Cannot delete default or admin roles")
+
+ if name in self.roles:
+ del self.roles[name]
+ # Remove role from all users
+ for user_roles in self.user_roles.values():
+ user_roles.discard(name)
+ self.save_data()
+ return True
+ return False
+
+ def assign_role(self, user: str, role_name: str):
+ """Assign a role to a user"""
+ if role_name not in self.roles:
+ raise ValueError(f"Role {role_name} does not exist")
+
+ if user not in self.user_roles:
+ self.user_roles[user] = {self.default_role.name}
+
+ self.user_roles[user].add(role_name)
+ self.save_data()
+
+ def remove_role(self, user: str, role_name: str):
+ """Remove a role from a user"""
+ if (
+ user in self.user_roles
+ and role_name in self.user_roles[user]
+ and role_name != self.default_role.name
+ ):
+ self.user_roles[user].remove(role_name)
+ self.save_data()
+
+ def get_user_permissions(self, user: str) -> DefaultPerms:
+ """Get combined permissions for a user"""
+ if user not in self.user_roles:
+ return self.default_role.permissions
+
+ perms = DefaultPerms.NONE
+ for role_name in self.user_roles[user]:
+ if role_name in self.roles:
+ perms |= self.roles[role_name].permissions
+
+ return perms
+
+ def has_permission(self, user: str, permission: DefaultPerms) -> bool:
+ """Check if user has specific permission"""
+ if not self.enabled:
+ return True
+ user_perms = self.get_user_permissions(user)
+ return (user_perms & permission) == permission

diff --git a/vendor/lxmfy/lxmfy/scheduler.py b/vendor/lxmfy/lxmfy/scheduler.py
new file mode 100644
index 00000000..493fcbc1
--- /dev/null
+++ b/vendor/lxmfy/lxmfy/scheduler.py
@@ -0,0 +1,194 @@
+"""Task scheduling system for LXMFy.
+
+This module provides cron-style scheduling and background task management
+for LXMFy bots.
+"""
+
+import logging
+import time
+from collections.abc import Callable
+from dataclasses import dataclass
+from datetime import datetime, timedelta
+from threading import Event, Thread
+
+logger = logging.getLogger(__name__)
+
+
+@dataclass
+class ScheduledTask:
+ """A scheduled task with cron-style timing.
+
+ Attributes:
+ name (str): The name of the task.
+ callback (Callable): The function to execute when the task runs.
+ cron_expr (str): A cron-style expression defining when the task should run (min hour day month weekday).
+ last_run (Optional[datetime]): The last time the task was run.
+ enabled (bool): Whether the task is currently enabled.
+
+ """
+
+ name: str
+ callback: Callable
+ cron_expr: str
+ last_run: datetime | None = None
+ enabled: bool = True
+
+ def should_run(self, current_time: datetime) -> bool:
+ """Check if the task should run at the given time.
+
+ Args:
+ current_time (datetime): The current datetime.
+
+ Returns:
+ bool: True if the task should run, False otherwise.
+
+ """
+ if not self.enabled:
+ return False
+
+ if self.last_run and current_time - self.last_run < timedelta(minutes=1):
+ return False
+
+ return self._match_cron(current_time)
+
+ def _match_cron(self, dt: datetime) -> bool:
+ """Match the datetime against the cron expression.
+
+ Args:
+ dt (datetime): The datetime to match.
+
+ Returns:
+ bool: True if the datetime matches the cron expression, False otherwise.
+
+ """
+ parts = self.cron_expr.split()
+ if len(parts) != 5:
+ return False
+
+ minute, hour, day, month, weekday = parts
+
+ return (
+ self._match_field(minute, dt.minute, 0, 59)
+ and self._match_field(hour, dt.hour, 0, 23)
+ and self._match_field(day, dt.day, 1, 31)
+ and self._match_field(month, dt.month, 1, 12)
+ and ScheduledTask._match_field(weekday, dt.weekday(), 0, 6)
+ )
+
+ @staticmethod
+ def _match_field(pattern: str, value: int, min_val: int, max_val: int) -> bool:
+ """Match a cron field pattern.
+
+ Args:
+ pattern (str): The cron field pattern to match.
+ value (int): The value to check against the pattern.
+ min_val (int): The minimum allowed value.
+ max_val (int): The maximum allowed value.
+
+ Returns:
+ bool: True if the value matches the pattern, False otherwise.
+
+ """
+ if pattern == "*":
+ return True
+
+ parts = pattern.split(",")
+ for part in parts:
+ if "-" in part:
+ start, end = map(int, part.split("-"))
+ if min_val <= start <= value <= end <= max_val:
+ return True
+ elif "/" in part:
+ step = int(part.split("/")[1])
+ if value % step == 0:
+ return True
+ elif int(part) == value:
+ return True
+
+ return False
+
+
+class TaskScheduler:
+ """Manages scheduled tasks and background processes."""
+
+ def __init__(self, bot):
+ """Initialize the TaskScheduler.
+
+ Args:
+ bot: The bot instance.
+
+ """
+ self.bot = bot
+ self.tasks: dict[str, ScheduledTask] = {}
+ self.background_tasks: list[Thread] = []
+ self.stop_event = Event()
+ self.logger = logging.getLogger(__name__)
+
+ def schedule(self, name: str, cron_expr: str):
+ """Decorator to schedule a task.
+
+ Args:
+ name (str): The name of the task.
+ cron_expr (str): The cron expression for the task.
+
+ """
+
+ def decorator(func):
+ """Adds the task to the scheduler."""
+ self.add_task(name, func, cron_expr)
+ return func
+
+ return decorator
+
+ def add_task(self, name: str, callback: Callable, cron_expr: str):
+ """Add a scheduled task.
+
+ Args:
+ name (str): The name of the task.
+ callback (Callable): The function to execute when the task runs.
+ cron_expr (str): A cron-style expression defining when the task should run.
+
+ """
+ self.tasks[name] = ScheduledTask(name, callback, cron_expr)
+
+ def remove_task(self, name: str):
+ """Remove a scheduled task.
+
+ Args:
+ name (str): The name of the task to remove.
+
+ """
+ self.tasks.pop(name, None)
+
+ def start(self):
+ """Start the scheduler."""
+ self.stop_event.clear()
+ scheduler_thread = Thread(target=self._scheduler_loop, daemon=True)
+ scheduler_thread.start()
+ self.background_tasks.append(scheduler_thread)
+
+ def stop(self):
+ """Stop the scheduler."""
+ self.stop_event.set()
+ for task in self.background_tasks:
+ task.join()
+ self.background_tasks.clear()
+
+ def _scheduler_loop(self):
+ """Main scheduler loop. Checks and runs tasks based on their cron expressions."""
+ while not self.stop_event.is_set():
+ current_time = datetime.now()
+
+ for task in self.tasks.values():
+ if task.should_run(current_time):
+ try:
+ task.callback()
+ task.last_run = current_time
+ except Exception as e:
+ self.logger.error(
+ "Error running task %s: %s",
+ task.name,
+ str(e),
+ )
+
+ time.sleep(60 - datetime.now().second)

diff --git a/vendor/lxmfy/lxmfy/signatures.py b/vendor/lxmfy/lxmfy/signatures.py
new file mode 100644
index 00000000..1f822761
--- /dev/null
+++ b/vendor/lxmfy/lxmfy/signatures.py
@@ -0,0 +1,300 @@
+"""Signature management module for LXMFy.
+
+This module provides cryptographic signing and verification capabilities
+for LXMF messages using RNS Identity.
+"""
+
+import logging
+
+import LXMF
+import RNS
+
+from .permissions import DefaultPerms
+
+logger = logging.getLogger(__name__)
+
+FIELD_SIGNATURE = 0xFA
+
+
+class SignatureManager:
+ """Manages cryptographic signing and verification of messages."""
+
+ def __init__(
+ self,
+ bot,
+ verification_enabled: bool = False,
+ require_signatures: bool = False,
+ request_unknown_identities: bool = False,
+ ):
+ """Initialize the SignatureManager.
+
+ Args:
+ bot: The LXMFBot instance.
+ verification_enabled: Whether signature verification is enabled.
+ require_signatures: Whether to reject unsigned messages.
+ request_unknown_identities: Whether to request unknown identities.
+
+ """
+ self.bot = bot
+ self.verification_enabled = verification_enabled
+ self.require_signatures = require_signatures
+ self.request_unknown_identities = request_unknown_identities
+ self.requested_identities = set()
+ self.logger = logging.getLogger(__name__)
+
+ def sign_message(self, message, identity: RNS.Identity) -> bytes:
+ """Sign an LXMF message using the provided identity.
+
+ Args:
+ message: The LXMF message to sign.
+ identity: The RNS identity to use for signing.
+
+ Returns:
+ The cryptographic signature as bytes.
+
+ """
+ try:
+ message_data = self._canonicalize_message(message)
+ signature = identity.sign(message_data)
+ return signature
+ except Exception as e:
+ self.logger.error("Failed to sign message: %s", str(e))
+ raise
+
+ def verify_message_signature(
+ self,
+ message,
+ signature: bytes,
+ sender_hash: str,
+ sender_identity: RNS.Identity = None,
+ ) -> bool:
+ """Verify a message signature against a sender identity.
+
+ Args:
+ message: The LXMF message that was signed.
+ signature: The cryptographic signature to verify.
+ sender_hash: Hex string of the sender's identity hash.
+ sender_identity: Optional RNS Identity object (for testing when recall fails).
+
+ Returns:
+ True if signature is valid, False otherwise.
+
+ """
+ try:
+ identity_to_use = sender_identity
+ if identity_to_use is None:
+ sender_hash_bytes = bytes.fromhex(sender_hash)
+ identity_to_use = RNS.Identity.recall(sender_hash_bytes)
+ if identity_to_use is None:
+ self.logger.warning(
+ "Could not recall identity for sender: %s",
+ sender_hash,
+ )
+ return False
+
+ if getattr(self.bot.config, "identity_pinning_enabled", False) is True:
+ pin_key = f"pin:{sender_hash}"
+ pinned_pub_key = self.bot.storage.get(pin_key)
+ current_pub_key = identity_to_use.get_public_key()
+
+ is_mock = False
+ try:
+ from unittest.mock import Mock
+
+ if isinstance(pinned_pub_key, Mock) or isinstance(
+ current_pub_key,
+ Mock,
+ ):
+ is_mock = True
+ except ImportError:
+ pass
+
+ if is_mock:
+ pass
+ elif pinned_pub_key:
+ if pinned_pub_key != current_pub_key:
+ self.logger.error(
+ "Identity pinning violation for %s! Expected %s, got %s",
+ sender_hash,
+ pinned_pub_key.hex()
+ if hasattr(pinned_pub_key, "hex")
+ else pinned_pub_key,
+ current_pub_key.hex()
+ if hasattr(current_pub_key, "hex")
+ else current_pub_key,
+ )
+ return False
+ else:
+ self.logger.info("Pinning identity for %s", sender_hash)
+ self.bot.storage.set(pin_key, current_pub_key)
+
+ message_data = self._canonicalize_message(message)
+ return identity_to_use.validate(signature, message_data)
+ except Exception as e:
+ self.logger.error("Failed to verify message signature: %s", str(e))
+ return False
+
+ @staticmethod
+ def _canonicalize_message(message) -> bytes:
+ """Create a canonical byte representation of a message for signing.
+
+ Args:
+ message: The LXMF message to canonicalize.
+
+ Returns:
+ Canonical byte representation of the message.
+
+ """
+ canonical_data = []
+ if message.source_hash:
+ canonical_data.append(
+ b"source:" + RNS.hexrep(message.source_hash, delimit=False).encode(),
+ )
+ if message.destination_hash:
+ canonical_data.append(
+ b"dest:" + RNS.hexrep(message.destination_hash, delimit=False).encode(),
+ )
+ if message.content:
+ canonical_data.append(b"content:" + message.content)
+ if message.title:
+ canonical_data.append(b"title:" + message.title)
+ if hasattr(message, "timestamp") and message.timestamp:
+ canonical_data.append(b"timestamp:" + str(message.timestamp).encode())
+ if hasattr(message, "fields") and message.fields:
+ sorted_fields = sorted(
+ (k, v) for k, v in message.fields.items() if k != FIELD_SIGNATURE
+ )
+ for field_id, field_data in sorted_fields:
+ canonical_data.append(
+ f"field_{field_id}:".encode() + str(field_data).encode(),
+ )
+ return b"|".join(canonical_data)
+
+ def should_verify_message(self, sender: str) -> bool:
+ """Determine if a message from the given sender should be verified.
+
+ Args:
+ sender: The sender's identity hash.
+
+ Returns:
+ True if the message should be verified, False otherwise.
+
+ """
+ if not self.verification_enabled:
+ return False
+ # Only skip verification if permissions are enabled and user has bypass permission
+ if (
+ hasattr(self.bot, "permissions")
+ and self.bot.permissions.enabled
+ and self.bot.permissions.has_permission(sender, DefaultPerms.BYPASS_SPAM)
+ ):
+ return False
+ return True
+
+ def handle_unsigned_message(self, sender: str, message_hash: str) -> bool:
+ """Handle a message that lacks a valid signature.
+
+ Args:
+ sender: The sender's identity hash.
+ message_hash: The message hash for logging.
+
+ Returns:
+ True if the message should be processed anyway, False if it should be rejected.
+
+ """
+ if self.require_signatures:
+ self.logger.warning(
+ "Rejected unsigned message from %s (hash: %s)",
+ sender,
+ message_hash,
+ )
+ return False
+ if self.verification_enabled:
+ self.logger.info(
+ "Accepted unsigned message from %s (hash: %s)",
+ sender,
+ message_hash,
+ )
+ return True
+
+
+def sign_outgoing_message(_bot, message: LXMF.LXMessage) -> LXMF.LXMessage:
+ """Prepare an outgoing message for signing.
+
+ Note: LXMF automatically signs messages during pack() using the source identity.
+ This function is kept for backwards compatibility but is essentially a pass-through.
+
+ Args:
+ _bot: The LXMFBot instance (unused, kept for backwards compatibility).
+ message: The LXMF message to sign.
+
+ Returns:
+ The message (LXMF will handle signing during pack()).
+
+ """
+ return message
+
+
+def verify_incoming_message(bot, message, sender: str) -> bool:
+ """Verify the signature of an incoming LXMF message using built-in LXMF validation.
+
+ Args:
+ bot: The LXMFBot instance.
+ message: The incoming LXMF message.
+ sender: The sender's identity hash.
+
+ Returns:
+ True if message should be processed, False if it should be rejected.
+
+ """
+ if not hasattr(bot, "signature_manager"):
+ return True
+
+ sig_manager = bot.signature_manager
+ if not sig_manager.should_verify_message(sender):
+ return True
+
+ if not message.signature_validated:
+ if message.unverified_reason == LXMF.LXMessage.SIGNATURE_INVALID:
+ logger.warning("Invalid LXMF signature for message from %s", sender)
+ return False
+ if message.unverified_reason == LXMF.LXMessage.SOURCE_UNKNOWN:
+ logger.debug(
+ "Could not verify message from %s - source identity unknown",
+ sender,
+ )
+
+ # Optionally request the identity from the network
+ if sig_manager.request_unknown_identities:
+ if sender not in sig_manager.requested_identities:
+ try:
+ sender_hash_bytes = bytes.fromhex(sender)
+ RNS.Transport.request_path(sender_hash_bytes)
+ sig_manager.requested_identities.add(sender)
+ logger.info(
+ "Requested unknown identity for %s from the network",
+ sender,
+ )
+ except Exception as e:
+ logger.error(
+ "Failed to request path for %s: %s",
+ sender,
+ str(e),
+ )
+
+ if sig_manager.require_signatures:
+ logger.warning("Rejected message from %s due to unknown source", sender)
+ return False
+ return True
+ logger.warning(
+ "Message from %s not validated (reason: %s)",
+ sender,
+ message.unverified_reason,
+ )
+ return sig_manager.handle_unsigned_message(
+ sender,
+ message.hash.hex() if message.hash else "unknown",
+ )
+
+ logger.debug("Verified LXMF signature for message from %s", sender)
+ return True

diff --git a/vendor/lxmfy/lxmfy/storage.py b/vendor/lxmfy/lxmfy/storage.py
new file mode 100644
index 00000000..c1a0295f
--- /dev/null
+++ b/vendor/lxmfy/lxmfy/storage.py
@@ -0,0 +1,590 @@
+"""Storage module for LXMFy bot framework.
+
+This module provides abstract and concrete storage implementations for persistent data storage.
+It includes a base StorageBackend interface and a JSON file-based implementation.
+The Storage class serves as a facade for the underlying storage backend.
+"""
+
+import base64
+import json
+import logging
+import sqlite3
+from abc import ABC, abstractmethod
+from datetime import datetime
+from pathlib import Path
+from typing import Any
+
+import RNS
+from LXMF import LXMessage
+
+from .attachments import Attachment, AttachmentType
+
+
+def serialize_value(obj: Any) -> Any:
+ """Serialize complex objects to JSON-compatible format.
+
+ Args:
+ obj: The object to serialize.
+
+ Returns:
+ A JSON-compatible representation of the object.
+
+ """
+ if isinstance(obj, (bytes, bytearray)):
+ return {"__type": "bytes", "data": base64.b64encode(obj).decode()}
+ if isinstance(obj, datetime):
+ return {"__type": "datetime", "data": obj.isoformat()}
+ if isinstance(obj, LXMessage):
+ msg_data = {
+ "__type": "LXMessage",
+ "source_hash": RNS.hexrep(obj.source_hash, delimit=False),
+ "destination_hash": RNS.hexrep(obj.destination_hash, delimit=False),
+ "content": base64.b64encode(obj.content).decode() if obj.content else None,
+ "title": obj.title,
+ "timestamp": obj.timestamp.isoformat() if obj.timestamp else None,
+ }
+
+ if hasattr(obj, "fields") and obj.fields:
+ msg_data["fields"] = {
+ str(k): serialize_value(v) for k, v in obj.fields.items()
+ }
+
+ return msg_data
+ if isinstance(obj, Attachment):
+ return {
+ "__type": "Attachment",
+ "type": obj.type,
+ "name": obj.name,
+ "data": base64.b64encode(obj.data).decode(),
+ "format": obj.format,
+ }
+ if isinstance(obj, (list, tuple)):
+ return [serialize_value(item) for item in obj]
+ if isinstance(obj, dict):
+ return {k: serialize_value(v) for k, v in obj.items()}
+ return obj
+
+
+def deserialize_value(obj: Any) -> Any:
+ """Deserialize from storage format.
+
+ Args:
+ obj: The object to deserialize.
+
+ Returns:
+ The deserialized object.
+
+ """
+ if isinstance(obj, dict):
+ if "__type" in obj:
+ if obj["__type"] == "bytes":
+ return base64.b64decode(obj["data"])
+ if obj["__type"] == "datetime":
+ return datetime.fromisoformat(obj["data"])
+ if obj["__type"] == "LXMessage":
+ msg_data = {
+ "source_hash": obj["source_hash"],
+ "destination_hash": obj["destination_hash"],
+ "content": base64.b64decode(obj["data"])
+ if obj["content"]
+ else None,
+ "title": obj["title"],
+ "timestamp": datetime.fromisoformat(obj["timestamp"])
+ if obj["timestamp"]
+ else None,
+ }
+ if "fields" in obj:
+ msg_data["fields"] = deserialize_value(obj["fields"])
+ return msg_data
+ if obj["__type"] == "Attachment":
+ return Attachment(
+ type=AttachmentType(obj["type"]),
+ name=obj["name"],
+ data=base64.b64decode(obj["data"]),
+ format=obj["format"],
+ )
+ return {k: deserialize_value(v) for k, v in obj.items()}
+ if isinstance(obj, list):
+ return [deserialize_value(item) for item in obj]
+ return obj
+
+
+class StorageBackend(ABC):
+ """Abstract base class for storage backends."""
+
+ @abstractmethod
+ def get(self, key: str, default: Any = None) -> Any:
+ """Retrieve a value from storage.
+
+ Args:
+ key: The key to retrieve.
+ default: The default value to return if the key is not found.
+
+ Returns:
+ The value associated with the key, or the default value if not found.
+
+ """
+
+ @abstractmethod
+ def set(self, key: str, value: Any) -> None:
+ """Store a value in storage.
+
+ Args:
+ key: The key to store the value under.
+ value: The value to store.
+
+ """
+
+ @abstractmethod
+ def delete(self, key: str) -> None:
+ """Delete a value from storage.
+
+ Args:
+ key: The key to delete.
+
+ """
+
+ @abstractmethod
+ def exists(self, key: str) -> bool:
+ """Check if a key exists in storage.
+
+ Args:
+ key: The key to check.
+
+ Returns:
+ True if the key exists, False otherwise.
+
+ """
+
+ @abstractmethod
+ def scan(self, prefix: str) -> list:
+ """Scan for keys with a given prefix.
+
+ Args:
+ prefix: The prefix to scan for.
+
+ Returns:
+ A list of keys that start with the prefix.
+
+ """
+
+
+class JSONStorage(StorageBackend):
+ """JSON file-based storage backend."""
+
+ def __init__(self, directory: str):
+ """Initialize a new JSONStorage instance.
+
+ Args:
+ directory: The directory to store the JSON files in.
+
+ """
+ self.directory = Path(directory)
+ self.directory.mkdir(parents=True, exist_ok=True)
+ self.cache: dict[str, Any] = {}
+ self.logger = logging.getLogger(__name__)
+
+ def get(self, key: str, default: Any = None) -> Any:
+ """Retrieve a value from storage.
+
+ Args:
+ key: The key to retrieve.
+ default: The default value to return if the key is not found.
+
+ Returns:
+ The value associated with the key, or the default value if not found.
+
+ """
+ if key in self.cache:
+ return self.cache[key]
+
+ file_path = self.directory / f"{key}.json"
+ try:
+ if file_path.exists():
+ with open(file_path) as f:
+ data = json.load(f)
+ self.cache[key] = data
+ return data
+ except Exception as e:
+ self.logger.error("Error reading %s: %s", key, str(e))
+ return default
+
+ def set(self, key: str, value: Any) -> None:
+ """Store a value in storage.
+
+ Args:
+ key: The key to store the value under.
+ value: The value to store.
+
+ """
+ file_path = self.directory / f"{key}.json"
+ try:
+ with open(file_path, "w") as f:
+ json.dump(value, f, indent=2)
+ self.cache[key] = value
+ except Exception as e:
+ self.logger.error("Error writing %s: %s", key, str(e))
+ raise
+
+ def delete(self, key: str) -> None:
+ """Delete a value from storage.
+
+ Args:
+ key: The key to delete.
+
+ """
+ file_path = self.directory / f"{key}.json"
+ try:
+ if file_path.exists():
+ file_path.unlink()
+ self.cache.pop(key, None)
+ except Exception as e:
+ self.logger.error("Error deleting %s: %s", key, str(e))
+ raise
+
+ def exists(self, key: str) -> bool:
+ """Check if a key exists in storage.
+
+ Args:
+ key: The key to check.
+
+ Returns:
+ True if the key exists, False otherwise.
+
+ """
+ return (self.directory / f"{key}.json").exists()
+
+ def scan(self, prefix: str) -> list:
+ """Scan for keys with a given prefix.
+
+ Args:
+ prefix: The prefix to scan for.
+
+ Returns:
+ A list of keys that start with the prefix.
+
+ """
+ results = []
+ try:
+ for file in self.directory.glob(f"{prefix}*.json"):
+ key = file.stem
+ if key.startswith(prefix):
+ results.append(key)
+ except Exception as e:
+ self.logger.error("Error scanning with prefix %s: %s", prefix, str(e))
+ return results
+
+
+class SQLiteStorage(StorageBackend):
+ """SQLite database storage backend."""
+
+ def __init__(self, database_path: str):
+ """Initialize a new SQLiteStorage instance.
+
+ Args:
+ database_path: The path to the SQLite database file.
+
+ """
+ self.database_path = database_path
+ self.cache: dict[str, Any] = {}
+ self.logger = logging.getLogger(__name__)
+ self._ensure_db_dir()
+ self._init_db()
+
+ def _ensure_db_dir(self):
+ """Ensure the database directory exists."""
+ db_path = Path(self.database_path)
+ db_dir = db_path.parent
+ try:
+ db_dir.mkdir(parents=True, exist_ok=True)
+ except Exception as e:
+ self.logger.error(
+ "Failed to create database directory %s: %s",
+ db_dir,
+ str(e),
+ )
+ raise
+
+ def _init_db(self):
+ """Initialize the database table."""
+ try:
+ with sqlite3.connect(self.database_path) as conn:
+ conn.execute("""
+ CREATE TABLE IF NOT EXISTS key_value (
+ key TEXT PRIMARY KEY,
+ value TEXT,
+ type TEXT,
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
+ )
+ """)
+ conn.execute("""
+ CREATE INDEX IF NOT EXISTS idx_key_prefix ON key_value(key)
+ """)
+ except sqlite3.OperationalError as e:
+ self.logger.error(
+ "Failed to initialize database at %s: %s",
+ self.database_path,
+ str(e),
+ )
+ raise
+ except Exception as e:
+ self.logger.error("Unexpected error initializing database: %s", str(e))
+ raise
+
+ def get(self, key: str, default: Any = None) -> Any:
+ """Retrieve a value from storage.
+
+ Args:
+ key: The key to retrieve.
+ default: The default value to return if the key is not found.
+
+ Returns:
+ The value associated with the key, or the default value if not found.
+
+ """
+ if key in self.cache:
+ return self.cache[key]
+
+ try:
+ with sqlite3.connect(self.database_path) as conn:
+ cursor = conn.execute(
+ "SELECT value FROM key_value WHERE key = ?",
+ (key,),
+ )
+ row = cursor.fetchone()
+ if row:
+ try:
+ value = json.loads(row[0])
+ self.cache[key] = value
+ return value
+ except json.JSONDecodeError:
+ return row[0]
+ except Exception as e:
+ self.logger.error("Error reading %s: %s", key, str(e))
+ return default
+
+ def set(self, key: str, value: Any) -> None:
+ """Store a value in storage.
+
+ Args:
+ key: The key to store the value under.
+ value: The value to store.
+
+ """
+ try:
+ if isinstance(value, (dict, list)):
+ serialized = json.dumps(value)
+ else:
+ serialized = str(value)
+
+ with sqlite3.connect(self.database_path) as conn:
+ conn.execute(
+ """
+ INSERT OR REPLACE INTO key_value (key, value, type, updated_at)
+ VALUES (?, ?, ?, CURRENT_TIMESTAMP)
+ """,
+ (key, serialized, type(value).__name__),
+ )
+ self.cache[key] = value
+ except Exception as e:
+ self.logger.error("Error writing %s: %s", key, str(e))
+ raise
+
+ def delete(self, key: str) -> None:
+ """Delete a value from storage.
+
+ Args:
+ key: The key to delete.
+
+ """
+ try:
+ with sqlite3.connect(self.database_path) as conn:
+ conn.execute("DELETE FROM key_value WHERE key = ?", (key,))
+ self.cache.pop(key, None)
+ except Exception as e:
+ self.logger.error("Error deleting %s: %s", key, str(e))
+ raise
+
+ def exists(self, key: str) -> bool:
+ """Check if a key exists in storage.
+
+ Args:
+ key: The key to check.
+
+ Returns:
+ True if the key exists, False otherwise.
+
+ """
+ try:
+ with sqlite3.connect(self.database_path) as conn:
+ cursor = conn.execute("SELECT 1 FROM key_value WHERE key = ?", (key,))
+ return cursor.fetchone() is not None
+ except Exception as e:
+ self.logger.error("Error checking existence of %s: %s", key, str(e))
+ return False
+
+ def scan(self, prefix: str) -> list:
+ """Scan for keys with a given prefix.
+
+ Args:
+ prefix: The prefix to scan for.
+
+ Returns:
+ A list of keys that start with the prefix.
+
+ """
+ try:
+ with sqlite3.connect(self.database_path) as conn:
+ cursor = conn.execute(
+ "SELECT key FROM key_value WHERE key LIKE ? ORDER BY key",
+ (f"{prefix}%",),
+ )
+ return [row[0] for row in cursor.fetchall()]
+ except Exception as e:
+ self.logger.error("Error scanning with prefix %s: %s", prefix, str(e))
+ return []
+
+
+class MemoryStorage(StorageBackend):
+ """In-memory storage backend."""
+
+ def __init__(self):
+ """Initialize a new MemoryStorage instance."""
+ self.data: dict[str, Any] = {}
+ self.logger = logging.getLogger(__name__)
+
+ def get(self, key: str, default: Any = None) -> Any:
+ """Retrieve a value from storage."""
+ return self.data.get(key, default)
+
+ def set(self, key: str, value: Any) -> None:
+ """Store a value in storage."""
+ self.data[key] = value
+
+ def delete(self, key: str) -> None:
+ """Delete a value from storage."""
+ self.data.pop(key, None)
+
+ def exists(self, key: str) -> bool:
+ """Check if a key exists in storage."""
+ return key in self.data
+
+ def scan(self, prefix: str) -> list:
+ """Scan for keys with a given prefix."""
+ return [k for k in self.data if k.startswith(prefix)]
+
+
+class Storage:
+ """Facade for the underlying storage backend."""
+
+ def __init__(self, backend: StorageBackend):
+ """Initialize a new Storage instance.
+
+ Args:
+ backend: The storage backend to use.
+
+ """
+ self.backend = backend
+
+ def get(self, key: str, default: Any = None) -> Any:
+ """Retrieve a value from storage.
+
+ Args:
+ key: The key to retrieve.
+ default: The default value to return if the key is not found.
+
+ Returns:
+ The value associated with the key, or the default value if not found.
+
+ """
+ value = self.backend.get(key, default)
+ return deserialize_value(value)
+
+ def set(self, key: str, value: Any) -> None:
+ """Store a value in storage.
+
+ Args:
+ key: The key to store the value under.
+ value: The value to store.
+
+ """
+ serialized = serialize_value(value)
+ self.backend.set(key, serialized)
+
+ def delete(self, key: str) -> None:
+ """Delete a value from storage.
+
+ Args:
+ key: The key to delete.
+
+ """
+ self.backend.delete(key)
+
+ def exists(self, key: str) -> bool:
+ """Check if a key exists in storage.
+
+ Args:
+ key: The key to check.
+
+ Returns:
+ True if the key exists, False otherwise.
+
+ """
+ return self.backend.exists(key)
+
+ def scan(self, prefix: str) -> list:
+ """Scan for keys with a given prefix.
+
+ Args:
+ prefix: The prefix to scan for.
+
+ Returns:
+ A list of keys that start with the prefix.
+
+ """
+ return self.backend.scan(prefix)
+
+ def get_role_data(self, role_name: str) -> dict:
+ """Helper method for permission system.
+
+ Args:
+ role_name: The name of the role.
+
+ Returns:
+ The role data.
+
+ """
+ return self.get(f"roles:{role_name}", {})
+
+ def set_role_data(self, role_name: str, data: dict):
+ """Helper method for permission system.
+
+ Args:
+ role_name: The name of the role.
+ data: The role data.
+
+ """
+ self.set(f"roles:{role_name}", data)
+
+ def get_user_roles(self, user_hash: str) -> list[str]:
+ """Helper method for permission system.
+
+ Args:
+ user_hash: The hash of the user.
+
+ Returns:
+ The list of roles for the user.
+
+ """
+ return self.get(f"user_roles:{user_hash}", [])
+
+ def set_user_roles(self, user_hash: str, roles: list[str]):
+ """Helper method for permission system.
+
+ Args:
+ user_hash: The hash of the user.
+ roles: The list of roles for the user.
+
+ """
+ self.set(f"user_roles:{user_hash}", roles)

diff --git a/vendor/lxmfy/lxmfy/templates/__init__.py b/vendor/lxmfy/lxmfy/templates/__init__.py
new file mode 100644
index 00000000..0be030ed
--- /dev/null
+++ b/vendor/lxmfy/lxmfy/templates/__init__.py
@@ -0,0 +1,11 @@
+"""Templates module for LXMFy bot framework.
+
+This module provides ready-to-use bot templates with different feature sets.
+"""
+
+from .cog_test_bot import CogTestBot
+from .echo_bot import EchoBot
+from .note_bot import NoteBot
+from .reminder_bot import ReminderBot
+
+__all__ = ["CogTestBot", "EchoBot", "NoteBot", "ReminderBot"]

diff --git a/vendor/lxmfy/lxmfy/templates/cog_test_bot.py b/vendor/lxmfy/lxmfy/templates/cog_test_bot.py
new file mode 100644
index 00000000..8ac52cdd
--- /dev/null
+++ b/vendor/lxmfy/lxmfy/templates/cog_test_bot.py
@@ -0,0 +1,94 @@
+"""CogTest Bot Template - Tests cog command loading functionality.
+
+This template demonstrates proper cog usage and serves as a test case
+for the command loading system.
+"""
+
+from lxmfy import Command, LXMFBot
+from lxmfy.commands import Cog
+
+
+class TestCog(Cog):
+ """Test cog with various command types to verify loading works correctly."""
+
+ def __init__(self, bot):
+ super().__init__(bot)
+
+ @Command(name="cogtest", description="Test command from cog")
+ def cog_test_command(self, msg):
+ """Test basic cog command functionality."""
+ msg.reply("✅ Cog command working correctly!")
+
+ @Command(
+ name="cogadmin",
+ description="Admin test command from cog",
+ admin_only=True,
+ )
+ def cog_admin_command(self, msg):
+ """Test admin cog command functionality."""
+ msg.reply("🔒 Admin cog command working correctly!")
+
+ @Command(
+ name="coghelp",
+ description="Help command from cog using Command decorator",
+ )
+ def cog_help_command(self, msg):
+ """Test Command decorator in cog."""
+ msg.reply("""
+🔧 CogTest Bot Commands:
+/cogtest - Test basic cog command
+/cogadmin - Test admin cog command (admin only)
+/coghelp - This help message
+/status - Bot status
+ """)
+
+
+class CogTestBot:
+ """Template bot that uses cogs for testing command loading."""
+
+ def __init__(self, name="CogTestBot", test_mode=False):
+ self.bot = LXMFBot(
+ name=name,
+ announce=600,
+ announce_immediately=True,
+ admins=set(),
+ hot_reloading=True,
+ rate_limit=5,
+ cooldown=60,
+ max_warnings=3,
+ warning_timeout=300,
+ command_prefix="/",
+ cogs_enabled=False,
+ permissions_enabled=False,
+ storage_type="json",
+ storage_path="cogtest_data",
+ first_message_enabled=True,
+ test_mode=test_mode,
+ )
+
+ self.bot.add_cog(TestCog(self.bot))
+
+ @self.bot.command(name="status", description="Show bot status")
+ def status_command(msg):
+ """Show bot status and loaded commands."""
+ cog_commands = [
+ cmd
+ for cmd in self.bot.commands
+ if cmd in ["cogtest", "cogadmin", "coghelp"]
+ ]
+ msg.reply(f"""
+🤖 CogTest Bot Status:
+- Commands loaded: {len(self.bot.commands)}
+- Cog commands: {", ".join(cog_commands)}
+- Cogs loaded: {len(self.bot.cogs)}
+- Test status: {"✅ PASS" if len(cog_commands) == 3 else "❌ FAIL"}
+ """)
+
+ def run(self):
+ """Run the bot."""
+ self.bot.run()
+
+
+def setup(bot):
+ """Setup function for when used as a cog module."""
+ bot.add_cog(TestCog(bot))

diff --git a/vendor/lxmfy/lxmfy/templates/echo_bot.py b/vendor/lxmfy/lxmfy/templates/echo_bot.py
new file mode 100644
index 00000000..3445b244
--- /dev/null
+++ b/vendor/lxmfy/lxmfy/templates/echo_bot.py
@@ -0,0 +1,91 @@
+"""Simple echo bot template with cryptographic signature verification."""
+
+from lxmfy import IconAppearance, LXMFBot, pack_icon_appearance_field
+
+
+class EchoBot:
+ """A simple echo bot that repeats messages with cryptographic signature verification."""
+
+ def __init__(self, test_mode=False):
+ """Initializes the EchoBot with signature verification enabled."""
+ self.bot = LXMFBot(
+ name="Echo Bot",
+ announce=600,
+ command_prefix="",
+ first_message_enabled=True,
+ test_mode=test_mode,
+ )
+ self.setup_commands()
+ self.setup_message_handlers()
+
+ # Define and pack the icon appearance for the bot
+ icon_data = IconAppearance(
+ icon_name="forum",
+ fg_color=b"\xad\xd8\xe6",
+ bg_color=b"\x3b\x59\x98",
+ ) # Light blue on dark blue
+ self.icon_lxmf_field = pack_icon_appearance_field(icon_data)
+
+ def setup_message_handlers(self):
+ """Sets up the bot's message handlers."""
+
+ @self.bot.on_message()
+ def echo_non_command_messages(sender, message):
+ """Echoes back messages that are not commands."""
+ content = message.content.decode("utf-8").strip()
+ if not content:
+ return False
+
+ # Check if this would be processed as a command
+ command_name = content.split()[0]
+ if command_name in self.bot.commands:
+ return False # Let the command handler take care of it
+
+ # Echo the message since it's not a command
+ self.bot.send(
+ sender,
+ content,
+ lxmf_fields=self.icon_lxmf_field,
+ )
+ return False # Continue processing (though no commands will match)
+
+ def setup_commands(self):
+ """Sets up the bot's commands and event handlers."""
+
+ @self.bot.command(name="echo", description="Echo back your message")
+ def echo(ctx):
+ """Echoes back the message provided by the user.
+
+ Args:
+ ctx: The command context.
+
+ """
+ if ctx.args:
+ ctx.reply(" ".join(ctx.args), lxmf_fields=self.icon_lxmf_field)
+ else:
+ ctx.reply("Usage: echo <message>", lxmf_fields=self.icon_lxmf_field)
+
+ @self.bot.on_first_message()
+ def welcome(sender, message):
+ """Greets the user on their first message and explains the bot's functionality.
+
+ Args:
+ sender: The sender of the message.
+ message: The message received.
+
+ Returns:
+ True to indicate the message was handled.
+
+ """
+ content = message.content.decode("utf-8").strip()
+ self.bot.send(
+ sender,
+ f"Hi! I'm an echo bot, You said: {content}\n\n"
+ "Try: echo <message> to make me repeat things!",
+ lxmf_fields=self.icon_lxmf_field,
+ )
+ return True
+
+ def run(self):
+ """Runs the bot."""
+ self.bot.run()

diff --git a/vendor/lxmfy/lxmfy/templates/note_bot.py b/vendor/lxmfy/lxmfy/templates/note_bot.py
new file mode 100644
index 00000000..6a8f2fda
--- /dev/null
+++ b/vendor/lxmfy/lxmfy/templates/note_bot.py
@@ -0,0 +1,137 @@
+"""Note-taking bot with JSON storage."""
+
+from datetime import datetime
+
+from lxmfy import LXMFBot
+
+
+class NoteBot:
+ """A bot that allows users to save and retrieve notes."""
+
+ def __init__(self, test_mode=False):
+ """Initializes the NoteBot with basic configurations and sets up commands."""
+ self.bot = LXMFBot(
+ name="Note Bot",
+ announce=600,
+ command_prefix="/",
+ storage_type="json",
+ storage_path="data/notes",
+ test_mode=test_mode,
+ )
+ self.setup_commands()
+
+ def setup_commands(self):
+ """Sets up the bot's commands: save note, list notes, search notes."""
+
+ @self.bot.command(name="note", description="Save a note")
+ def save_note(ctx):
+ """Saves a note for the user.
+
+ Args:
+ ctx: The command context.
+
+ """
+ if not ctx.args:
+ ctx.reply("Usage: /note <your note>")
+ return
+
+ note = {
+ "text": " ".join(ctx.args),
+ "timestamp": datetime.now().isoformat(),
+ "tags": [w[1:] for w in ctx.args if w.startswith("#")],
+ }
+
+ notes = self.bot.storage.get(f"notes:{ctx.sender}", [])
+ notes.append(note)
+ self.bot.storage.set(f"notes:{ctx.sender}", notes)
+ ctx.reply("Note saved!")
+
+ @self.bot.command(name="notes", description="List your notes")
+ def list_notes(ctx):
+ """Lists the user's notes, with options to show all, the last 10, or notes with a specific tag.
+
+ Args:
+ ctx: The command context.
+
+ """
+ if not ctx.args:
+ notes = self.bot.storage.get(f"notes:{ctx.sender}", [])
+ if not notes:
+ ctx.reply("You haven't saved any notes yet!")
+ return
+
+ response = "Your Notes:\n"
+ for i, note in enumerate(notes[-10:], 1):
+ tags = (
+ " ".join(f"#{tag}" for tag in note["tags"])
+ if note["tags"]
+ else ""
+ )
+ response += f"{i}. {note['text']} {tags}\n"
+
+ if len(notes) > 10:
+ response += f"\nShowing last 10 of {len(notes)} notes. Use /notes all to see all."
+ ctx.reply(response)
+ elif ctx.args[0] == "all":
+ notes = self.bot.storage.get(f"notes:{ctx.sender}", [])
+ if not notes:
+ ctx.reply("You haven't saved any notes yet!")
+ return
+
+ response = "All Your Notes:\n"
+ for i, note in enumerate(notes, 1):
+ tags = (
+ " ".join(f"#{tag}" for tag in note["tags"])
+ if note["tags"]
+ else ""
+ )
+ response += f"{i}. {note['text']} {tags}\n"
+ ctx.reply(response)
+ elif ctx.args[0].startswith("#"):
+ tag = ctx.args[0][1:]
+ notes = self.bot.storage.get(f"notes:{ctx.sender}", [])
+ tagged_notes = [n for n in notes if tag in n["tags"]]
+
+ if not tagged_notes:
+ ctx.reply(f"No notes found with tag #{tag}")
+ return
+
+ response = f"Notes tagged #{tag}:\n"
+ for i, note in enumerate(tagged_notes, 1):
+ tags = (
+ " ".join(f"#{t}" for t in note["tags"]) if note["tags"] else ""
+ )
+ response += f"{i}. {note['text']} {tags}\n"
+ ctx.reply(response)
+
+ @self.bot.command(name="search", description="Search your notes")
+ def search_notes(ctx):
+ """Searches the user's notes for a specific term.
+
+ Args:
+ ctx: The command context.
+
+ """
+ if not ctx.args:
+ ctx.reply("Usage: /search <text>")
+ return
+
+ search_term = " ".join(ctx.args).lower()
+ notes = self.bot.storage.get(f"notes:{ctx.sender}", [])
+ matches = [n for n in notes if search_term in n["text"].lower()]
+
+ if not matches:
+ ctx.reply(f"No notes found containing '{search_term}'")
+ return
+
+ response = f"Notes containing '{search_term}':\n"
+ for i, note in enumerate(matches, 1):
+ tags = (
+ " ".join(f"#{tag}" for tag in note["tags"]) if note["tags"] else ""
+ )
+ response += f"{i}. {note['text']} {tags}\n"
+ ctx.reply(response)
+
+ def run(self):
+ """Runs the bot."""
+ self.bot.run()

diff --git a/vendor/lxmfy/lxmfy/templates/reminder_bot.py b/vendor/lxmfy/lxmfy/templates/reminder_bot.py
new file mode 100644
index 00000000..920d5b18
--- /dev/null
+++ b/vendor/lxmfy/lxmfy/templates/reminder_bot.py
@@ -0,0 +1,128 @@
+"""Reminder bot with SQLite storage."""
+
+import re
+import time
+from datetime import datetime, timedelta
+
+from lxmfy import LXMFBot
+
+
+class ReminderBot:
+ """A bot that reminds users of tasks at specified times."""
+
+ def __init__(self, test_mode=False):
+ """Initializes the ReminderBot, sets up the bot instance,
+ configures commands, and sets up the reminder check loop.
+ """
+ self.bot = LXMFBot(
+ name="Reminder Bot",
+ announce=600,
+ command_prefix="/",
+ storage_type="sqlite",
+ storage_path="data/reminders.db",
+ test_mode=test_mode,
+ )
+ self.setup_commands()
+ self.bot.scheduler.add_task(
+ "check_reminders",
+ self._check_reminders,
+ "*/1 * * * *", # Run every minute
+ )
+
+ def setup_commands(self):
+ """Sets up the bot's commands, specifically the 'remind' and 'list' commands."""
+
+ @self.bot.command(name="remind", description="Set a reminder")
+ def remind(ctx):
+ """Sets a reminder for the user.
+
+ Args:
+ ctx: The command context containing the sender and message.
+
+ """
+ if not ctx.args or len(ctx.args) < 2:
+ ctx.reply(
+ "Usage: /remind <time> <message>\nExample: /remind 1h30m Buy groceries",
+ )
+ return
+
+ time_str = ctx.args[0].lower()
+ message = " ".join(ctx.args[1:])
+
+ total_minutes = 0
+ time_parts = re.findall(r"(\d+)([dhm])", time_str)
+
+ for value, unit in time_parts:
+ if unit == "d":
+ total_minutes += int(value) * 24 * 60
+ elif unit == "h":
+ total_minutes += int(value) * 60
+ elif unit == "m":
+ total_minutes += int(value)
+
+ if total_minutes == 0:
+ ctx.reply(
+ "Invalid time format. Use combinations of d (days), h (hours), m (minutes)",
+ )
+ return
+
+ remind_time = datetime.now() + timedelta(minutes=total_minutes)
+
+ reminder = {
+ "user": ctx.sender,
+ "message": message,
+ "time": remind_time.timestamp(),
+ "created": time.time(),
+ }
+
+ reminders = self.bot.storage.get("reminders", [])
+ reminders.append(reminder)
+ self.bot.storage.set("reminders", reminders)
+
+ ctx.reply(
+ f"I'll remind you about '{message}' at {remind_time.strftime('%Y-%m-%d %H:%M:%S')}",
+ )
+
+ @self.bot.command(name="list", description="List your reminders")
+ def list_reminders(ctx):
+ """Lists the user's active reminders.
+
+ Args:
+ ctx: The command context.
+
+ """
+ reminders = self.bot.storage.get("reminders", [])
+ user_reminders = [r for r in reminders if r["user"] == ctx.sender]
+
+ if not user_reminders:
+ ctx.reply("You have no active reminders")
+ return
+
+ response = "Your reminders:\n"
+ for i, reminder in enumerate(user_reminders, 1):
+ remind_time = datetime.fromtimestamp(reminder["time"])
+ response += f"{i}. {reminder['message']} (at {remind_time.strftime('%Y-%m-%d %H:%M:%S')})\n"
+
+ ctx.reply(response)
+
+ def _check_reminders(self):
+ """Checks for reminders that are due and sends notifications."""
+ reminders = self.bot.storage.get("reminders", [])
+ current_time = time.time()
+
+ due_reminders = [r for r in reminders if r["time"] <= current_time]
+ remaining = [r for r in reminders if r["time"] > current_time]
+
+ for reminder in due_reminders:
+ self.bot.send(
+ reminder["user"],
+ f"Reminder: {reminder['message']}",
+ "Reminder",
+ )
+
+ if due_reminders:
+ self.bot.storage.set("reminders", remaining)
+
+ def run(self):
+ """Runs the bot."""
+ self.bot.run()

diff --git a/vendor/lxmfy/lxmfy/transport.py b/vendor/lxmfy/lxmfy/transport.py
new file mode 100644
index 00000000..bf3aa7b8
--- /dev/null
+++ b/vendor/lxmfy/lxmfy/transport.py
@@ -0,0 +1,219 @@
+"""Transport module for LXMFy bot framework.
+
+This module provides transport layer functionality for establishing and managing
+network connections using Reticulum Network Stack (RNS). It handles path discovery,
+link establishment, and caching of active connections. The Transport class serves
+as the main interface for network operations, with support for path and request
+handlers.
+"""
+
+import logging
+import time
+from collections.abc import Callable
+from dataclasses import dataclass
+
+import RNS
+
+from .permissions import DefaultPerms
+
+
+@dataclass
+class PathInfo:
+ """Data class to store path information.
+
+ Attributes:
+ next_hop (Optional[bytes]): The next hop in the path.
+ hops (int): The number of hops in the path.
+ updated_at (int): The timestamp of the last path update.
+
+ """
+
+ next_hop: bytes | None
+ hops: int
+ updated_at: int
+
+
+class Transport:
+ """Manages network transport for LXMFy, handling links and paths."""
+
+ def __init__(self, bot, storage):
+ """Initializes the Transport instance.
+
+ Args:
+ bot: The LXMFBot instance.
+ storage: The storage backend to use for caching paths.
+
+ """
+ self.bot = bot
+ self.storage = storage
+ self.logger = logging.getLogger(__name__)
+ self.cached_links = {}
+ self.paths = {}
+ self._path_handlers = []
+ self._request_handlers = {}
+
+ def register_path_handler(self, handler: Callable):
+ """Registers a handler for path discovery events.
+
+ Args:
+ handler (Callable): The handler function to register.
+
+ """
+ self._path_handlers.append(handler)
+
+ def deregister_path_handler(self, handler: Callable):
+ """Deregisters a path discovery event handler.
+
+ Args:
+ handler (Callable): The handler function to deregister.
+
+ """
+ if handler in self._path_handlers:
+ self._path_handlers.remove(handler)
+
+ def register_request_handler(self, request_type: str, handler: Callable):
+ """Registers a handler for specific request types.
+
+ Args:
+ request_type (str): The request type to handle.
+ handler (Callable): The handler function to register.
+
+ """
+ self._request_handlers[request_type] = handler
+
+ def deregister_request_handler(self, request_type: str):
+ """Deregisters a request handler for a specific request type.
+
+ Args:
+ request_type (str): The request type to deregister.
+
+ """
+ self._request_handlers.pop(request_type, None)
+
+ def load_paths(self):
+ """Loads cached paths from storage."""
+ self.paths = self.storage.get("transport:paths", {})
+
+ def save_paths(self):
+ """Saves cached paths to storage."""
+ self.storage.set("transport:paths", self.paths)
+
+ def establish_link(
+ self,
+ destination_hash: bytes,
+ timeout: int = 15,
+ app_name: str = "lxmf",
+ *aspects: str,
+ ) -> RNS.Link:
+ """Establish a link with path discovery.
+
+ Args:
+ destination_hash (bytes): The destination hash to establish a link with.
+ timeout (int): The timeout in seconds for path discovery.
+ app_name: The app name for the destination (default: "lxmf").
+ *aspects: Additional aspects for the destination (default: "delivery" if none provided).
+
+ Returns:
+ RNS.Link: The established RNS link.
+
+ Raises:
+ Exception: If the user does not have permission to establish links or if path lookup times out.
+
+ """
+ if not aspects:
+ aspects = ("delivery",)
+
+ sender = RNS.hexrep(destination_hash, delimit=False)
+ if not self.bot.permissions.has_permission(sender, DefaultPerms.USE_BOT):
+ raise Exception("User does not have permission to establish links")
+
+ self.load_paths()
+ try:
+ if RNS.Transport.has_path(destination_hash):
+ return self._create_link(destination_hash, timeout, app_name, *aspects)
+
+ RNS.Transport.request_path(destination_hash)
+
+ path_timeout = time.time() + timeout
+ while time.time() < path_timeout:
+ if RNS.Transport.has_path(destination_hash):
+ return self._create_link(
+ destination_hash,
+ timeout,
+ app_name,
+ *aspects,
+ )
+ time.sleep(0.1)
+
+ raise Exception("Path lookup timed out")
+
+ except Exception as e:
+ self.logger.error("Error establishing link: %s", str(e))
+ raise
+ finally:
+ self.save_paths()
+
+ def _create_link(
+ self,
+ destination_hash: bytes,
+ timeout: int,
+ app_name: str = "lxmf",
+ *aspects: str,
+ ) -> RNS.Link:
+ """Create and establish a link.
+
+ Args:
+ destination_hash (bytes): The destination hash for the link.
+ timeout (int): The timeout in seconds for link establishment.
+ app_name: The app name for the destination.
+ *aspects: Additional aspects for the destination.
+
+ Returns:
+ RNS.Link: The established RNS link.
+
+ Raises:
+ Exception: If the identity is not found or if link establishment times out.
+
+ """
+ if not aspects:
+ aspects = ("delivery",)
+
+ try:
+ identity = RNS.Identity.recall(destination_hash)
+ if not identity:
+ raise Exception("Identity not found")
+
+ destination = RNS.Destination(
+ identity,
+ RNS.Destination.OUT,
+ RNS.Destination.SINGLE,
+ app_name,
+ *aspects,
+ )
+
+ link = RNS.Link(destination)
+
+ start_time = time.time()
+ while time.time() - start_time < timeout:
+ if link.status == RNS.Link.ACTIVE:
+ self.cached_links[destination_hash] = link
+ return link
+ time.sleep(0.1)
+
+ raise Exception("Link establishment timed out")
+
+ except Exception as e:
+ self.logger.error("Error creating link: %s", str(e))
+ raise
+
+ def cleanup(self):
+ """Clean up inactive links."""
+ for link in list(self.cached_links.values()):
+ if link.status != RNS.Link.ACTIVE:
+ link.teardown()
+
+ self.cached_links = {
+ dest_hash: link
+ for dest_hash, link in self.cached_links.items()
+ if link.status == RNS.Link.ACTIVE
+ }

diff --git a/vendor/lxmfy/lxmfy/validation.py b/vendor/lxmfy/lxmfy/validation.py
new file mode 100644
index 00000000..0e99229a
--- /dev/null
+++ b/vendor/lxmfy/lxmfy/validation.py
@@ -0,0 +1,297 @@
+"""Validation module for LXMFy configuration and best practices."""
+
+import logging
+from dataclasses import dataclass
+from typing import Any
+
+from .storage import JSONStorage
+
+logger = logging.getLogger(__name__)
+
+
+@dataclass
+class ValidationResult:
+ """Result of a validation check.
+
+ Attributes:
+ valid (bool): Indicates whether the validation was successful.
+ messages (list[str]): A list of messages associated with the validation result.
+ severity (str): The severity level of the validation result ('error', 'warning', or 'info').
+
+ """
+
+ valid: bool
+ messages: list[str]
+ severity: str
+
+
+class ConfigValidator:
+ """Validates bot configuration settings."""
+
+ @staticmethod
+ def validate_config(config: Any) -> list[ValidationResult]:
+ """Validate the given bot configuration.
+
+ Args:
+ config (Any): The bot configuration object to validate.
+
+ Returns:
+ list[ValidationResult]: A list of validation results.
+
+ """
+ results = []
+
+ try:
+ if len(getattr(config, "name", "")) < 3:
+ results.append(
+ ValidationResult(
+ False,
+ ["Bot name should be at least 3 characters long"],
+ "error",
+ ),
+ )
+
+ announce = getattr(config, "announce", 0)
+ if 0 < announce < 300:
+ results.append(
+ ValidationResult(
+ False,
+ [
+ "Announce interval should be at least 300 seconds to avoid network spam",
+ ],
+ "warning",
+ ),
+ )
+
+ if getattr(config, "rate_limit", 0) > 10:
+ results.append(
+ ValidationResult(
+ False,
+ [
+ "Rate limit above 10 messages per minute may be too permissive",
+ ],
+ "warning",
+ ),
+ )
+
+ if getattr(config, "cooldown", 0) < 30:
+ results.append(
+ ValidationResult(
+ False,
+ ["Cooldown period should be at least 30 seconds"],
+ "warning",
+ ),
+ )
+
+ except Exception as e:
+ logger.error("Error during config validation: %s", str(e))
+ results.append(
+ ValidationResult(
+ False,
+ [f"Error validating configuration: {e!s}"],
+ "error",
+ ),
+ )
+
+ return results
+
+
+class BestPracticesChecker:
+ """Checks for bot implementation best practices."""
+
+ @staticmethod
+ def check_bot(bot: Any) -> list[ValidationResult]:
+ """Check the bot instance for best practices.
+
+ Args:
+ bot (Any): The bot instance to check.
+
+ Returns:
+ list[ValidationResult]: A list of validation results.
+
+ """
+ results = []
+
+ if not getattr(bot.config, "permissions_enabled", False):
+ results.append(
+ ValidationResult(
+ False,
+ [
+ "Permission system is disabled. Consider enabling it for better security",
+ ],
+ "warning",
+ ),
+ )
+
+ if getattr(bot, "command_prefix", None) is None:
+ results.append(
+ ValidationResult(
+ False,
+ ["Using no command prefix may cause high processing overhead"],
+ "warning",
+ ),
+ )
+
+ if not getattr(bot, "admins", None):
+ results.append(
+ ValidationResult(
+ False,
+ ["No admin users configured. Bot management will be limited"],
+ "warning",
+ ),
+ )
+
+ if getattr(bot.config, "storage_type", "") == "json":
+ results.append(
+ ValidationResult(
+ True,
+ [
+ "Consider using SQLite storage for better performance with large datasets",
+ ],
+ "info",
+ ),
+ )
+
+ sig_enabled = getattr(bot.config, "signature_verification_enabled", False)
+ sig_required = getattr(bot.config, "require_message_signatures", False)
+
+ if sig_enabled and sig_required:
+ results.append(
+ ValidationResult(
+ True,
+ [
+ "Strict signature verification enabled - all messages must be signed",
+ ],
+ "info",
+ ),
+ )
+ elif sig_enabled and not sig_required:
+ results.append(
+ ValidationResult(
+ True,
+ [
+ "Signature verification enabled but not required - unsigned messages will be logged",
+ ],
+ "info",
+ ),
+ )
+ elif not sig_enabled:
+ results.append(
+ ValidationResult(
+ False,
+ [
+ "Signature verification is disabled. Consider enabling it for enhanced security",
+ ],
+ "warning",
+ ),
+ )
+
+ return results
+
+
+class PerformanceAnalyzer:
+ """Analyzes bot configuration for performance optimization opportunities."""
+
+ @staticmethod
+ def analyze_bot(bot: Any) -> list[ValidationResult]:
+ """Analyze the bot instance for performance optimization opportunities.
+
+ Args:
+ bot (Any): The bot instance to analyze.
+
+ Returns:
+ list[ValidationResult]: A list of validation results.
+
+ """
+ results = []
+
+ if not hasattr(bot, "transport") or not hasattr(bot.transport, "cached_links"):
+ results.append(
+ ValidationResult(
+ False,
+ ["Link caching is not enabled. This may impact performance"],
+ "warning",
+ ),
+ )
+
+ if hasattr(bot, "queue") and getattr(bot.queue, "maxsize", 0) < 10:
+ results.append(
+ ValidationResult(
+ False,
+ ["Consider increasing queue size for better message handling"],
+ "info",
+ ),
+ )
+
+ if (
+ hasattr(bot, "storage")
+ and hasattr(bot.storage, "backend")
+ and isinstance(bot.storage.backend, JSONStorage)
+ ):
+ results.append(
+ ValidationResult(
+ True,
+ [
+ "SQLite backend recommended for better performance with large datasets",
+ ],
+ "info",
+ ),
+ )
+
+ return results
+
+
+def validate_bot(bot: Any) -> dict[str, list[ValidationResult]]:
+ """Run all validation checks on a bot instance.
+
+ Args:
+ bot (Any): The bot instance to validate.
+
+ Returns:
+ dict[str, list[ValidationResult]]: A dictionary containing validation results for different categories.
+
+ """
+ try:
+ return {
+ "config": ConfigValidator.validate_config(bot.config),
+ "best_practices": BestPracticesChecker.check_bot(bot),
+ "performance": PerformanceAnalyzer.analyze_bot(bot),
+ }
+ except Exception as e:
+ logger.error("Validation error: %s", str(e))
+ return {
+ "error": [
+ ValidationResult(
+ False,
+ [f"Error during validation: {e!s}"],
+ "error",
+ ),
+ ],
+ }
+
+
+def format_validation_results(results: dict[str, list[ValidationResult]]) -> str:
+ """Format validation results into a readable string.
+
+ Args:
+ results (dict[str, list[ValidationResult]]): A dictionary containing validation results.
+
+ Returns:
+ str: A formatted string representing the validation results.
+
+ """
+ output = []
+
+ for category, checks in results.items():
+ output.append(f"\n=== {category.upper()} ===")
+ for result in checks:
+ prefix = (
+ "[ERROR]"
+ if not result.valid and result.severity == "error"
+ else "[WARNING]"
+ if result.severity == "warning"
+ else "[INFO]"
+ )
+ output.extend(f"{prefix} {msg}" for msg in result.messages)
+
+ return "\n".join(output)

diff --git a/vendor/lxmfy/poetry.lock b/vendor/lxmfy/poetry.lock
new file mode 100644
index 00000000..27aee1ec
--- /dev/null
+++ b/vendor/lxmfy/poetry.lock
@@ -0,0 +1,1234 @@
+# This file is automatically @generated by Poetry 2.3.4 and should not be changed by hand.
+
+[[package]]
+name = "backports-tarfile"
+version = "1.2.0"
+description = "Backport of CPython tarfile module"
+optional = false
+python-versions = ">=3.8"
+groups = ["dev"]
+markers = "platform_machine != \"ppc64le\" and platform_machine != \"s390x\" and python_version == \"3.11\""
+files = [
+ {file = "backports.tarfile-1.2.0-py3-none-any.whl", hash = "sha256:77e284d754527b01fb1e6fa8a1afe577858ebe4e9dad8919e34c862cb399bc34"},
+ {file = "backports_tarfile-1.2.0.tar.gz", hash = "sha256:d75e02c268746e1b8144c278978b6e98e85de6ad16f8e4b0844a154557eca991"},
+]
+
+[package.extras]
+docs = ["furo", "jaraco.packaging (>=9.3)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"]
+testing = ["jaraco.test", "pytest (!=8.0.*)", "pytest (>=6,!=8.1.*)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)"]
+
+[[package]]
+name = "certifi"
+version = "2026.2.25"
+description = "Python package for providing Mozilla's CA Bundle."
+optional = false
+python-versions = ">=3.7"
+groups = ["dev"]
+files = [
+ {file = "certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa"},
+ {file = "certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7"},
+]
+
+[[package]]
+name = "cffi"
+version = "2.0.0"
+description = "Foreign Function Interface for Python calling C code."
+optional = false
+python-versions = ">=3.9"
+groups = ["main", "dev"]
+files = [
+ {file = "cffi-2.0.0-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44"},
+ {file = "cffi-2.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49"},
+ {file = "cffi-2.0.0-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:53f77cbe57044e88bbd5ed26ac1d0514d2acf0591dd6bb02a3ae37f76811b80c"},
+ {file = "cffi-2.0.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3e837e369566884707ddaf85fc1744b47575005c0a229de3327f8f9a20f4efeb"},
+ {file = "cffi-2.0.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5eda85d6d1879e692d546a078b44251cdd08dd1cfb98dfb77b670c97cee49ea0"},
+ {file = "cffi-2.0.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9332088d75dc3241c702d852d4671613136d90fa6881da7d770a483fd05248b4"},
+ {file = "cffi-2.0.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc7de24befaeae77ba923797c7c87834c73648a05a4bde34b3b7e5588973a453"},
+ {file = "cffi-2.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cf364028c016c03078a23b503f02058f1814320a56ad535686f90565636a9495"},
+ {file = "cffi-2.0.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e11e82b744887154b182fd3e7e8512418446501191994dbf9c9fc1f32cc8efd5"},
+ {file = "cffi-2.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8ea985900c5c95ce9db1745f7933eeef5d314f0565b27625d9a10ec9881e1bfb"},
+ {file = "cffi-2.0.0-cp310-cp310-win32.whl", hash = "sha256:1f72fb8906754ac8a2cc3f9f5aaa298070652a0ffae577e0ea9bd480dc3c931a"},
+ {file = "cffi-2.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:b18a3ed7d5b3bd8d9ef7a8cb226502c6bf8308df1525e1cc676c3680e7176739"},
+ {file = "cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe"},
+ {file = "cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c"},
+ {file = "cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92"},
+ {file = "cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93"},
+ {file = "cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5"},
+ {file = "cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664"},
+ {file = "cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26"},
+ {file = "cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9"},
+ {file = "cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414"},
+ {file = "cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743"},
+ {file = "cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5"},
+ {file = "cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5"},
+ {file = "cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d"},
+ {file = "cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d"},
+ {file = "cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c"},
+ {file = "cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe"},
+ {file = "cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062"},
+ {file = "cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e"},
+ {file = "cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037"},
+ {file = "cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba"},
+ {file = "cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94"},
+ {file = "cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187"},
+ {file = "cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18"},
+ {file = "cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5"},
+ {file = "cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6"},
+ {file = "cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb"},
+ {file = "cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca"},
+ {file = "cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b"},
+ {file = "cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b"},
+ {file = "cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2"},
+ {file = "cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3"},
+ {file = "cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26"},
+ {file = "cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c"},
+ {file = "cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b"},
+ {file = "cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27"},
+ {file = "cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75"},
+ {file = "cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91"},
+ {file = "cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5"},
+ {file = "cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13"},
+ {file = "cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b"},
+ {file = "cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c"},
+ {file = "cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef"},
+ {file = "cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775"},
+ {file = "cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205"},
+ {file = "cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1"},
+ {file = "cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f"},
+ {file = "cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25"},
+ {file = "cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad"},
+ {file = "cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9"},
+ {file = "cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d"},
+ {file = "cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c"},
+ {file = "cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8"},
+ {file = "cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc"},
+ {file = "cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592"},
+ {file = "cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512"},
+ {file = "cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4"},
+ {file = "cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e"},
+ {file = "cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6"},
+ {file = "cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9"},
+ {file = "cffi-2.0.0-cp39-cp39-macosx_10_13_x86_64.whl", hash = "sha256:fe562eb1a64e67dd297ccc4f5addea2501664954f2692b69a76449ec7913ecbf"},
+ {file = "cffi-2.0.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:de8dad4425a6ca6e4e5e297b27b5c824ecc7581910bf9aee86cb6835e6812aa7"},
+ {file = "cffi-2.0.0-cp39-cp39-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:4647afc2f90d1ddd33441e5b0e85b16b12ddec4fca55f0d9671fef036ecca27c"},
+ {file = "cffi-2.0.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3f4d46d8b35698056ec29bca21546e1551a205058ae1a181d871e278b0b28165"},
+ {file = "cffi-2.0.0-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e6e73b9e02893c764e7e8d5bb5ce277f1a009cd5243f8228f75f842bf937c534"},
+ {file = "cffi-2.0.0-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:cb527a79772e5ef98fb1d700678fe031e353e765d1ca2d409c92263c6d43e09f"},
+ {file = "cffi-2.0.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:61d028e90346df14fedc3d1e5441df818d095f3b87d286825dfcbd6459b7ef63"},
+ {file = "cffi-2.0.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:0f6084a0ea23d05d20c3edcda20c3d006f9b6f3fefeac38f59262e10cef47ee2"},
+ {file = "cffi-2.0.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:1cd13c99ce269b3ed80b417dcd591415d3372bcac067009b6e0f59c7d4015e65"},
+ {file = "cffi-2.0.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:89472c9762729b5ae1ad974b777416bfda4ac5642423fa93bd57a09204712322"},
+ {file = "cffi-2.0.0-cp39-cp39-win32.whl", hash = "sha256:2081580ebb843f759b9f617314a24ed5738c51d2aee65d31e02f6f7a2b97707a"},
+ {file = "cffi-2.0.0-cp39-cp39-win_amd64.whl", hash = "sha256:b882b3df248017dba09d6b16defe9b5c407fe32fc7c65a9c69798e6175601be9"},
+ {file = "cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529"},
+]
+markers = {main = "platform_python_implementation != \"PyPy\"", dev = "platform_machine != \"ppc64le\" and platform_machine != \"s390x\" and sys_platform == \"linux\" and platform_python_implementation != \"PyPy\""}
+
+[package.dependencies]
+pycparser = {version = "*", markers = "implementation_name != \"PyPy\""}
+
+[[package]]
+name = "charset-normalizer"
+version = "3.4.7"
+description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet."
+optional = false
+python-versions = ">=3.7"
+groups = ["dev"]
+files = [
+ {file = "charset_normalizer-3.4.7-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cdd68a1fb318e290a2077696b7eb7a21a49163c455979c639bf5a5dcdc46617d"},
+ {file = "charset_normalizer-3.4.7-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e17b8d5d6a8c47c85e68ca8379def1303fd360c3e22093a807cd34a71cd082b8"},
+ {file = "charset_normalizer-3.4.7-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:511ef87c8aec0783e08ac18565a16d435372bc1ac25a91e6ac7f5ef2b0bff790"},
+ {file = "charset_normalizer-3.4.7-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:007d05ec7321d12a40227aae9e2bc6dca73f3cb21058999a1df9e193555a9dcc"},
+ {file = "charset_normalizer-3.4.7-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf29836da5119f3c8a8a70667b0ef5fdca3bb12f80fd06487cfa575b3909b393"},
+ {file = "charset_normalizer-3.4.7-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:12d8baf840cc7889b37c7c770f478adea7adce3dcb3944d02ec87508e2dcf153"},
+ {file = "charset_normalizer-3.4.7-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d560742f3c0d62afaccf9f41fe485ed69bd7661a241f86a3ef0f0fb8b1a397af"},
+ {file = "charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b14b2d9dac08e28bb8046a1a0434b1750eb221c8f5b87a68f4fa11a6f97b5e34"},
+ {file = "charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:bc17a677b21b3502a21f66a8cc64f5bfad4df8a0b8434d661666f8ce90ac3af1"},
+ {file = "charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:750e02e074872a3fad7f233b47734166440af3cdea0add3e95163110816d6752"},
+ {file = "charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:4e5163c14bffd570ef2affbfdd77bba66383890797df43dc8b4cc7d6f500bf53"},
+ {file = "charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:6ed74185b2db44f41ef35fd1617c5888e59792da9bbc9190d6c7300617182616"},
+ {file = "charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:94e1885b270625a9a828c9793b4d52a64445299baa1fea5a173bf1d3dd9a1a5a"},
+ {file = "charset_normalizer-3.4.7-cp310-cp310-win32.whl", hash = "sha256:6785f414ae0f3c733c437e0f3929197934f526d19dfaa75e18fdb4f94c6fb374"},
+ {file = "charset_normalizer-3.4.7-cp310-cp310-win_amd64.whl", hash = "sha256:6696b7688f54f5af4462118f0bfa7c1621eeb87154f77fa04b9295ce7a8f2943"},
+ {file = "charset_normalizer-3.4.7-cp310-cp310-win_arm64.whl", hash = "sha256:66671f93accb62ed07da56613636f3641f1a12c13046ce91ffc923721f23c008"},
+ {file = "charset_normalizer-3.4.7-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7641bb8895e77f921102f72833904dcd9901df5d6d72a2ab8f31d04b7e51e4e7"},
+ {file = "charset_normalizer-3.4.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:202389074300232baeb53ae2569a60901f7efadd4245cf3a3bf0617d60b439d7"},
+ {file = "charset_normalizer-3.4.7-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:30b8d1d8c52a48c2c5690e152c169b673487a2a58de1ec7393196753063fcd5e"},
+ {file = "charset_normalizer-3.4.7-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:532bc9bf33a68613fd7d65e4b1c71a6a38d7d42604ecf239c77392e9b4e8998c"},
+ {file = "charset_normalizer-3.4.7-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2fe249cb4651fd12605b7288b24751d8bfd46d35f12a20b1ba33dea122e690df"},
+ {file = "charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:65bcd23054beab4d166035cabbc868a09c1a49d1efe458fe8e4361215df40265"},
+ {file = "charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:08e721811161356f97b4059a9ba7bafb23ea5ee2255402c42881c214e173c6b4"},
+ {file = "charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e060d01aec0a910bdccb8be71faf34e7799ce36950f8294c8bf612cba65a2c9e"},
+ {file = "charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:38c0109396c4cfc574d502df99742a45c72c08eff0a36158b6f04000043dbf38"},
+ {file = "charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:1c2a768fdd44ee4a9339a9b0b130049139b8ce3c01d2ce09f67f5a68048d477c"},
+ {file = "charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:1a87ca9d5df6fe460483d9a5bbf2b18f620cbed41b432e2bddb686228282d10b"},
+ {file = "charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:d635aab80466bc95771bb78d5370e74d36d1fe31467b6b29b8b57b2a3cd7d22c"},
+ {file = "charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ae196f021b5e7c78e918242d217db021ed2a6ace2bc6ae94c0fc596221c7f58d"},
+ {file = "charset_normalizer-3.4.7-cp311-cp311-win32.whl", hash = "sha256:adb2597b428735679446b46c8badf467b4ca5f5056aae4d51a19f9570301b1ad"},
+ {file = "charset_normalizer-3.4.7-cp311-cp311-win_amd64.whl", hash = "sha256:8e385e4267ab76874ae30db04c627faaaf0b509e1ccc11a95b3fc3e83f855c00"},
+ {file = "charset_normalizer-3.4.7-cp311-cp311-win_arm64.whl", hash = "sha256:d4a48e5b3c2a489fae013b7589308a40146ee081f6f509e047e0e096084ceca1"},
+ {file = "charset_normalizer-3.4.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46"},
+ {file = "charset_normalizer-3.4.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2"},
+ {file = "charset_normalizer-3.4.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b"},
+ {file = "charset_normalizer-3.4.7-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a"},
+ {file = "charset_normalizer-3.4.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116"},
+ {file = "charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb"},
+ {file = "charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1"},
+ {file = "charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15"},
+ {file = "charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5"},
+ {file = "charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d"},
+ {file = "charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7"},
+ {file = "charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464"},
+ {file = "charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49"},
+ {file = "charset_normalizer-3.4.7-cp312-cp312-win32.whl", hash = "sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c"},
+ {file = "charset_normalizer-3.4.7-cp312-cp312-win_amd64.whl", hash = "sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6"},
+ {file = "charset_normalizer-3.4.7-cp312-cp312-win_arm64.whl", hash = "sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d"},
+ {file = "charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063"},
+ {file = "charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c"},
+ {file = "charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66"},
+ {file = "charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18"},
+ {file = "charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd"},
+ {file = "charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215"},
+ {file = "charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859"},
+ {file = "charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8"},
+ {file = "charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5"},
+ {file = "charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832"},
+ {file = "charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6"},
+ {file = "charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48"},
+ {file = "charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a"},
+ {file = "charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e"},
+ {file = "charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110"},
+ {file = "charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b"},
+ {file = "charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0"},
+ {file = "charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a"},
+ {file = "charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b"},
+ {file = "charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41"},
+ {file = "charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e"},
+ {file = "charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae"},
+ {file = "charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18"},
+ {file = "charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b"},
+ {file = "charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356"},
+ {file = "charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab"},
+ {file = "charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46"},
+ {file = "charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44"},
+ {file = "charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72"},
+ {file = "charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10"},
+ {file = "charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f"},
+ {file = "charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246"},
+ {file = "charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24"},
+ {file = "charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79"},
+ {file = "charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960"},
+ {file = "charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4"},
+ {file = "charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e"},
+ {file = "charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1"},
+ {file = "charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44"},
+ {file = "charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e"},
+ {file = "charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3"},
+ {file = "charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0"},
+ {file = "charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e"},
+ {file = "charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb"},
+ {file = "charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe"},
+ {file = "charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0"},
+ {file = "charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c"},
+ {file = "charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d"},
+ {file = "charset_normalizer-3.4.7-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:e5f4d355f0a2b1a31bc3edec6795b46324349c9cb25eed068049e4f472fb4259"},
+ {file = "charset_normalizer-3.4.7-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:16d971e29578a5e97d7117866d15889a4a07befe0e87e703ed63cd90cb348c01"},
+ {file = "charset_normalizer-3.4.7-cp38-cp38-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:dca4bbc466a95ba9c0234ef56d7dd9509f63da22274589ebd4ed7f1f4d4c54e3"},
+ {file = "charset_normalizer-3.4.7-cp38-cp38-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e80c8378d8f3d83cd3164da1ad2df9e37a666cdde7b1cb2298ed0b558064be30"},
+ {file = "charset_normalizer-3.4.7-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:36836d6ff945a00b88ba1e4572d721e60b5b8c98c155d465f56ad19d68f23734"},
+ {file = "charset_normalizer-3.4.7-cp38-cp38-manylinux_2_31_armv7l.whl", hash = "sha256:bd9b23791fe793e4968dba0c447e12f78e425c59fc0e3b97f6450f4781f3ee60"},
+ {file = "charset_normalizer-3.4.7-cp38-cp38-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:aef65cd602a6d0e0ff6f9930fcb1c8fec60dd2cfcb6facaf4bdb0e5873042db0"},
+ {file = "charset_normalizer-3.4.7-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:82b271f5137d07749f7bf32f70b17ab6eaabedd297e75dce75081a24f76eb545"},
+ {file = "charset_normalizer-3.4.7-cp38-cp38-musllinux_1_2_armv7l.whl", hash = "sha256:1efde3cae86c8c273f1eb3b287be7d8499420cf2fe7585c41d370d3e790054a5"},
+ {file = "charset_normalizer-3.4.7-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:c593052c465475e64bbfe5dbd81680f64a67fdc752c56d7a0ae205dc8aeefe0f"},
+ {file = "charset_normalizer-3.4.7-cp38-cp38-musllinux_1_2_riscv64.whl", hash = "sha256:af21eb4409a119e365397b2adbaca4c9ccab56543a65d5dbd9f920d6ac29f686"},
+ {file = "charset_normalizer-3.4.7-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:84c018e49c3bf790f9c2771c45e9313a08c2c2a6342b162cd650258b57817706"},
+ {file = "charset_normalizer-3.4.7-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:dd915403e231e6b1809fe9b6d9fc55cf8fb5e02765ac625d9cd623342a7905d7"},
+ {file = "charset_normalizer-3.4.7-cp38-cp38-win32.whl", hash = "sha256:320ade88cfb846b8cd6b4ddf5ee9e80ee0c1f52401f2456b84ae1ae6a1a5f207"},
+ {file = "charset_normalizer-3.4.7-cp38-cp38-win_amd64.whl", hash = "sha256:1dc8b0ea451d6e69735094606991f32867807881400f808a106ee1d963c46a83"},
+ {file = "charset_normalizer-3.4.7-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:177a0ba5f0211d488e295aaf82707237e331c24788d8d76c96c5a41594723217"},
+ {file = "charset_normalizer-3.4.7-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e0d51f618228538a3e8f46bd246f87a6cd030565e015803691603f55e12afb5"},
+ {file = "charset_normalizer-3.4.7-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:14265bfe1f09498b9d8ec91e9ec9fa52775edf90fcbde092b25f4a33d444fea9"},
+ {file = "charset_normalizer-3.4.7-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:87fad7d9ba98c86bcb41b2dc8dbb326619be2562af1f8ff50776a39e55721c5a"},
+ {file = "charset_normalizer-3.4.7-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f22dec1690b584cea26fade98b2435c132c1b5f68e39f5a0b7627cd7ae31f1dc"},
+ {file = "charset_normalizer-3.4.7-cp39-cp39-manylinux_2_31_armv7l.whl", hash = "sha256:d61f00a0869d77422d9b2aba989e2d24afa6ffd552af442e0e58de4f35ea6d00"},
+ {file = "charset_normalizer-3.4.7-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6370e8686f662e6a3941ee48ed4742317cafbe5707e36406e9df792cdb535776"},
+ {file = "charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:a6c5863edfbe888d9eff9c8b8087354e27618d9da76425c119293f11712a6319"},
+ {file = "charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:ed065083d0898c9d5b4bbec7b026fd755ff7454e6e8b73a67f8c744b13986e24"},
+ {file = "charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:2cd4a60d0e2fb04537162c62bbbb4182f53541fe0ede35cdf270a1c1e723cc42"},
+ {file = "charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:813c0e0132266c08eb87469a642cb30aaff57c5f426255419572aaeceeaa7bf4"},
+ {file = "charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:07d9e39b01743c3717745f4c530a6349eadbfa043c7577eef86c502c15df2c67"},
+ {file = "charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:c0f081d69a6e58272819b70288d3221a6ee64b98df852631c80f293514d3b274"},
+ {file = "charset_normalizer-3.4.7-cp39-cp39-win32.whl", hash = "sha256:8751d2787c9131302398b11e6c8068053dcb55d5a8964e114b6e196cf16cb366"},
+ {file = "charset_normalizer-3.4.7-cp39-cp39-win_amd64.whl", hash = "sha256:12a6fff75f6bc66711b73a2f0addfc4c8c15a20e805146a02d147a318962c444"},
+ {file = "charset_normalizer-3.4.7-cp39-cp39-win_arm64.whl", hash = "sha256:bb8cc7534f51d9a017b93e3e85b260924f909601c3df002bcdb58ddb4dc41a5c"},
+ {file = "charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d"},
+ {file = "charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5"},
+]
+
+[[package]]
+name = "colorama"
+version = "0.4.6"
+description = "Cross-platform colored terminal text."
+optional = false
+python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7"
+groups = ["dev"]
+markers = "sys_platform == \"win32\""
+files = [
+ {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"},
+ {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"},
+]
+
+[[package]]
+name = "coverage"
+version = "7.13.5"
+description = "Code coverage measurement for Python"
+optional = false
+python-versions = ">=3.10"
+groups = ["dev"]
+files = [
+ {file = "coverage-7.13.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e0723d2c96324561b9aa76fb982406e11d93cdb388a7a7da2b16e04719cf7ca5"},
+ {file = "coverage-7.13.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:52f444e86475992506b32d4e5ca55c24fc88d73bcbda0e9745095b28ef4dc0cf"},
+ {file = "coverage-7.13.5-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:704de6328e3d612a8f6c07000a878ff38181ec3263d5a11da1db294fa6a9bdf8"},
+ {file = "coverage-7.13.5-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a1a6d79a14e1ec1832cabc833898636ad5f3754a678ef8bb4908515208bf84f4"},
+ {file = "coverage-7.13.5-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:79060214983769c7ba3f0cee10b54c97609dca4d478fa1aa32b914480fd5738d"},
+ {file = "coverage-7.13.5-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:356e76b46783a98c2a2fe81ec79df4883a1e62895ea952968fb253c114e7f930"},
+ {file = "coverage-7.13.5-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0cef0cdec915d11254a7f549c1170afecce708d30610c6abdded1f74e581666d"},
+ {file = "coverage-7.13.5-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:dc022073d063b25a402454e5712ef9e007113e3a676b96c5f29b2bda29352f40"},
+ {file = "coverage-7.13.5-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:9b74db26dfea4f4e50d48a4602207cd1e78be33182bc9cbf22da94f332f99878"},
+ {file = "coverage-7.13.5-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:ad146744ca4fd09b50c482650e3c1b1f4dfa1d4792e0a04a369c7f23336f0400"},
+ {file = "coverage-7.13.5-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:c555b48be1853fe3997c11c4bd521cdd9a9612352de01fa4508f16ec341e6fe0"},
+ {file = "coverage-7.13.5-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:7034b5c56a58ae5e85f23949d52c14aca2cfc6848a31764995b7de88f13a1ea0"},
+ {file = "coverage-7.13.5-cp310-cp310-win32.whl", hash = "sha256:eb7fdf1ef130660e7415e0253a01a7d5a88c9c4d158bcf75cbbd922fd65a5b58"},
+ {file = "coverage-7.13.5-cp310-cp310-win_amd64.whl", hash = "sha256:3e1bb5f6c78feeb1be3475789b14a0f0a5b47d505bfc7267126ccbd50289999e"},
+ {file = "coverage-7.13.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:66a80c616f80181f4d643b0f9e709d97bcea413ecd9631e1dedc7401c8e6695d"},
+ {file = "coverage-7.13.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:145ede53ccbafb297c1c9287f788d1bc3efd6c900da23bf6931b09eafc931587"},
+ {file = "coverage-7.13.5-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0672854dc733c342fa3e957e0605256d2bf5934feeac328da9e0b5449634a642"},
+ {file = "coverage-7.13.5-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ec10e2a42b41c923c2209b846126c6582db5e43a33157e9870ba9fb70dc7854b"},
+ {file = "coverage-7.13.5-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be3d4bbad9d4b037791794ddeedd7d64a56f5933a2c1373e18e9e568b9141686"},
+ {file = "coverage-7.13.5-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4d2afbc5cc54d286bfb54541aa50b64cdb07a718227168c87b9e2fb8f25e1743"},
+ {file = "coverage-7.13.5-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3ad050321264c49c2fa67bb599100456fc51d004b82534f379d16445da40fb75"},
+ {file = "coverage-7.13.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7300c8a6d13335b29bb76d7651c66af6bd8658517c43499f110ddc6717bfc209"},
+ {file = "coverage-7.13.5-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:eb07647a5738b89baab047f14edd18ded523de60f3b30e75c2acc826f79c839a"},
+ {file = "coverage-7.13.5-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:9adb6688e3b53adffefd4a52d72cbd8b02602bfb8f74dcd862337182fd4d1a4e"},
+ {file = "coverage-7.13.5-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7c8d4bc913dd70b93488d6c496c77f3aff5ea99a07e36a18f865bca55adef8bd"},
+ {file = "coverage-7.13.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0e3c426ffc4cd952f54ee9ffbdd10345709ecc78a3ecfd796a57236bfad0b9b8"},
+ {file = "coverage-7.13.5-cp311-cp311-win32.whl", hash = "sha256:259b69bb83ad9894c4b25be2528139eecba9a82646ebdda2d9db1ba28424a6bf"},
+ {file = "coverage-7.13.5-cp311-cp311-win_amd64.whl", hash = "sha256:258354455f4e86e3e9d0d17571d522e13b4e1e19bf0f8596bcf9476d61e7d8a9"},
+ {file = "coverage-7.13.5-cp311-cp311-win_arm64.whl", hash = "sha256:bff95879c33ec8da99fc9b6fe345ddb5be6414b41d6d1ad1c8f188d26f36e028"},
+ {file = "coverage-7.13.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:460cf0114c5016fa841214ff5564aa4864f11948da9440bc97e21ad1f4ba1e01"},
+ {file = "coverage-7.13.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0e223ce4b4ed47f065bfb123687686512e37629be25cc63728557ae7db261422"},
+ {file = "coverage-7.13.5-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6e3370441f4513c6252bf042b9c36d22491142385049243253c7e48398a15a9f"},
+ {file = "coverage-7.13.5-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:03ccc709a17a1de074fb1d11f217342fb0d2b1582ed544f554fc9fc3f07e95f5"},
+ {file = "coverage-7.13.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3f4818d065964db3c1c66dc0fbdac5ac692ecbc875555e13374fdbe7eedb4376"},
+ {file = "coverage-7.13.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:012d5319e66e9d5a218834642d6c35d265515a62f01157a45bcc036ecf947256"},
+ {file = "coverage-7.13.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8dd02af98971bdb956363e4827d34425cb3df19ee550ef92855b0acb9c7ce51c"},
+ {file = "coverage-7.13.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f08fd75c50a760c7eb068ae823777268daaf16a80b918fa58eea888f8e3919f5"},
+ {file = "coverage-7.13.5-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:843ea8643cf967d1ac7e8ecd4bb00c99135adf4816c0c0593fdcc47b597fcf09"},
+ {file = "coverage-7.13.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:9d44d7aa963820b1b971dbecd90bfe5fe8f81cff79787eb6cca15750bd2f79b9"},
+ {file = "coverage-7.13.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:7132bed4bd7b836200c591410ae7d97bf7ae8be6fc87d160b2bd881df929e7bf"},
+ {file = "coverage-7.13.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a698e363641b98843c517817db75373c83254781426e94ada3197cabbc2c919c"},
+ {file = "coverage-7.13.5-cp312-cp312-win32.whl", hash = "sha256:bdba0a6b8812e8c7df002d908a9a2ea3c36e92611b5708633c50869e6d922fdf"},
+ {file = "coverage-7.13.5-cp312-cp312-win_amd64.whl", hash = "sha256:d2c87e0c473a10bffe991502eac389220533024c8082ec1ce849f4218dded810"},
+ {file = "coverage-7.13.5-cp312-cp312-win_arm64.whl", hash = "sha256:bf69236a9a81bdca3bff53796237aab096cdbf8d78a66ad61e992d9dac7eb2de"},
+ {file = "coverage-7.13.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5ec4af212df513e399cf11610cc27063f1586419e814755ab362e50a85ea69c1"},
+ {file = "coverage-7.13.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:941617e518602e2d64942c88ec8499f7fbd49d3f6c4327d3a71d43a1973032f3"},
+ {file = "coverage-7.13.5-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:da305e9937617ee95c2e39d8ff9f040e0487cbf1ac174f777ed5eddd7a7c1f26"},
+ {file = "coverage-7.13.5-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:78e696e1cc714e57e8b25760b33a8b1026b7048d270140d25dafe1b0a1ee05a3"},
+ {file = "coverage-7.13.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:02ca0eed225b2ff301c474aeeeae27d26e2537942aa0f87491d3e147e784a82b"},
+ {file = "coverage-7.13.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:04690832cbea4e4663d9149e05dba142546ca05cb1848816760e7f58285c970a"},
+ {file = "coverage-7.13.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0590e44dd2745c696a778f7bab6aa95256de2cbc8b8cff4f7db8ff09813d6969"},
+ {file = "coverage-7.13.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d7cfad2d6d81dd298ab6b89fe72c3b7b05ec7544bdda3b707ddaecff8d25c161"},
+ {file = "coverage-7.13.5-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:e092b9499de38ae0fbfbc603a74660eb6ff3e869e507b50d85a13b6db9863e15"},
+ {file = "coverage-7.13.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:48c39bc4a04d983a54a705a6389512883d4a3b9862991b3617d547940e9f52b1"},
+ {file = "coverage-7.13.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2d3807015f138ffea1ed9afeeb8624fd781703f2858b62a8dd8da5a0994c57b6"},
+ {file = "coverage-7.13.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ee2aa19e03161671ec964004fb74b2257805d9710bf14a5c704558b9d8dbaf17"},
+ {file = "coverage-7.13.5-cp313-cp313-win32.whl", hash = "sha256:ce1998c0483007608c8382f4ff50164bfc5bd07a2246dd272aa4043b75e61e85"},
+ {file = "coverage-7.13.5-cp313-cp313-win_amd64.whl", hash = "sha256:631efb83f01569670a5e866ceb80fe483e7c159fac6f167e6571522636104a0b"},
+ {file = "coverage-7.13.5-cp313-cp313-win_arm64.whl", hash = "sha256:f4cd16206ad171cbc2470dbea9103cf9a7607d5fe8c242fdf1edf36174020664"},
+ {file = "coverage-7.13.5-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0428cbef5783ad91fe240f673cc1f76b25e74bbfe1a13115e4aa30d3f538162d"},
+ {file = "coverage-7.13.5-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e0b216a19534b2427cc201a26c25da4a48633f29a487c61258643e89d28200c0"},
+ {file = "coverage-7.13.5-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:972a9cd27894afe4bc2b1480107054e062df08e671df7c2f18c205e805ccd806"},
+ {file = "coverage-7.13.5-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4b59148601efcd2bac8c4dbf1f0ad6391693ccf7a74b8205781751637076aee3"},
+ {file = "coverage-7.13.5-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:505d7083c8b0c87a8fa8c07370c285847c1f77739b22e299ad75a6af6c32c5c9"},
+ {file = "coverage-7.13.5-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:60365289c3741e4db327e7baff2a4aaacf22f788e80fa4683393891b70a89fbd"},
+ {file = "coverage-7.13.5-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1b88c69c8ef5d4b6fe7dea66d6636056a0f6a7527c440e890cf9259011f5e606"},
+ {file = "coverage-7.13.5-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5b13955d31d1633cf9376908089b7cebe7d15ddad7aeaabcbe969a595a97e95e"},
+ {file = "coverage-7.13.5-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:f70c9ab2595c56f81a89620e22899eea8b212a4041bd728ac6f4a28bf5d3ddd0"},
+ {file = "coverage-7.13.5-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:084b84a8c63e8d6fc7e3931b316a9bcafca1458d753c539db82d31ed20091a87"},
+ {file = "coverage-7.13.5-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:ad14385487393e386e2ea988b09d62dd42c397662ac2dabc3832d71253eee479"},
+ {file = "coverage-7.13.5-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:7f2c47b36fe7709a6e83bfadf4eefb90bd25fbe4014d715224c4316f808e59a2"},
+ {file = "coverage-7.13.5-cp313-cp313t-win32.whl", hash = "sha256:67e9bc5449801fad0e5dff329499fb090ba4c5800b86805c80617b4e29809b2a"},
+ {file = "coverage-7.13.5-cp313-cp313t-win_amd64.whl", hash = "sha256:da86cdcf10d2519e10cabb8ac2de03da1bcb6e4853790b7fbd48523332e3a819"},
+ {file = "coverage-7.13.5-cp313-cp313t-win_arm64.whl", hash = "sha256:0ecf12ecb326fe2c339d93fc131816f3a7367d223db37817208905c89bded911"},
+ {file = "coverage-7.13.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fbabfaceaeb587e16f7008f7795cd80d20ec548dc7f94fbb0d4ec2e038ce563f"},
+ {file = "coverage-7.13.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9bb2a28101a443669a423b665939381084412b81c3f8c0fcfbac57f4e30b5b8e"},
+ {file = "coverage-7.13.5-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bd3a2fbc1c6cccb3c5106140d87cc6a8715110373ef42b63cf5aea29df8c217a"},
+ {file = "coverage-7.13.5-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6c36ddb64ed9d7e496028d1d00dfec3e428e0aabf4006583bb1839958d280510"},
+ {file = "coverage-7.13.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:380e8e9084d8eb38db3a9176a1a4f3c0082c3806fa0dc882d1d87abc3c789247"},
+ {file = "coverage-7.13.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e808af52a0513762df4d945ea164a24b37f2f518cbe97e03deaa0ee66139b4d6"},
+ {file = "coverage-7.13.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e301d30dd7e95ae068671d746ba8c34e945a82682e62918e41b2679acd2051a0"},
+ {file = "coverage-7.13.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:800bc829053c80d240a687ceeb927a94fd108bbdc68dfbe505d0d75ab578a882"},
+ {file = "coverage-7.13.5-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:0b67af5492adb31940ee418a5a655c28e48165da5afab8c7fa6fd72a142f8740"},
+ {file = "coverage-7.13.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:c9136ff29c3a91e25b1d1552b5308e53a1e0653a23e53b6366d7c2dcbbaf8a16"},
+ {file = "coverage-7.13.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:cff784eef7f0b8f6cb28804fbddcfa99f89efe4cc35fb5627e3ac58f91ed3ac0"},
+ {file = "coverage-7.13.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:68a4953be99b17ac3c23b6efbc8a38330d99680c9458927491d18700ef23ded0"},
+ {file = "coverage-7.13.5-cp314-cp314-win32.whl", hash = "sha256:35a31f2b1578185fbe6aa2e74cea1b1d0bbf4c552774247d9160d29b80ed56cc"},
+ {file = "coverage-7.13.5-cp314-cp314-win_amd64.whl", hash = "sha256:2aa055ae1857258f9e0045be26a6d62bdb47a72448b62d7b55f4820f361a2633"},
+ {file = "coverage-7.13.5-cp314-cp314-win_arm64.whl", hash = "sha256:1b11eef33edeae9d142f9b4358edb76273b3bfd30bc3df9a4f95d0e49caf94e8"},
+ {file = "coverage-7.13.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:10a0c37f0b646eaff7cce1874c31d1f1ccb297688d4c747291f4f4c70741cc8b"},
+ {file = "coverage-7.13.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b5db73ba3c41c7008037fa731ad5459fc3944cb7452fc0aa9f822ad3533c583c"},
+ {file = "coverage-7.13.5-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:750db93a81e3e5a9831b534be7b1229df848b2e125a604fe6651e48aa070e5f9"},
+ {file = "coverage-7.13.5-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9ddb4f4a5479f2539644be484da179b653273bca1a323947d48ab107b3ed1f29"},
+ {file = "coverage-7.13.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d8a7a2049c14f413163e2bdabd37e41179b1d1ccb10ffc6ccc4b7a718429c607"},
+ {file = "coverage-7.13.5-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1c85e0b6c05c592ea6d8768a66a254bfb3874b53774b12d4c89c481eb78cb90"},
+ {file = "coverage-7.13.5-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:777c4d1eff1b67876139d24288aaf1817f6c03d6bae9c5cc8d27b83bcfe38fe3"},
+ {file = "coverage-7.13.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6697e29b93707167687543480a40f0db8f356e86d9f67ddf2e37e2dfd91a9dab"},
+ {file = "coverage-7.13.5-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:8fdf453a942c3e4d99bd80088141c4c6960bb232c409d9c3558e2dbaa3998562"},
+ {file = "coverage-7.13.5-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:32ca0c0114c9834a43f045a87dcebd69d108d8ffb666957ea65aa132f50332e2"},
+ {file = "coverage-7.13.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:8769751c10f339021e2638cd354e13adeac54004d1941119b2c96fe5276d45ea"},
+ {file = "coverage-7.13.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cec2d83125531bd153175354055cdb7a09987af08a9430bd173c937c6d0fba2a"},
+ {file = "coverage-7.13.5-cp314-cp314t-win32.whl", hash = "sha256:0cd9ed7a8b181775459296e402ca4fb27db1279740a24e93b3b41942ebe4b215"},
+ {file = "coverage-7.13.5-cp314-cp314t-win_amd64.whl", hash = "sha256:301e3b7dfefecaca37c9f1aa6f0049b7d4ab8dd933742b607765d757aca77d43"},
+ {file = "coverage-7.13.5-cp314-cp314t-win_arm64.whl", hash = "sha256:9dacc2ad679b292709e0f5fc1ac74a6d4d5562e424058962c7bb0c658ad25e45"},
+ {file = "coverage-7.13.5-py3-none-any.whl", hash = "sha256:34b02417cf070e173989b3db962f7ed56d2f644307b2cf9d5a0f258e13084a61"},
+ {file = "coverage-7.13.5.tar.gz", hash = "sha256:c81f6515c4c40141f83f502b07bbfa5c240ba25bbe73da7b33f1e5b6120ff179"},
+]
+
+[package.extras]
+toml = ["tomli ; python_full_version <= \"3.11.0a6\""]
+
+[[package]]
+name = "cryptography"
+version = "46.0.7"
+description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers."
+optional = false
+python-versions = "!=3.9.0,!=3.9.1,>=3.8"
+groups = ["main", "dev"]
+files = [
+ {file = "cryptography-46.0.7-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:ea42cbe97209df307fdc3b155f1b6fa2577c0defa8f1f7d3be7d31d189108ad4"},
+ {file = "cryptography-46.0.7-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b36a4695e29fe69215d75960b22577197aca3f7a25b9cf9d165dcfe9d80bc325"},
+ {file = "cryptography-46.0.7-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5ad9ef796328c5e3c4ceed237a183f5d41d21150f972455a9d926593a1dcb308"},
+ {file = "cryptography-46.0.7-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:73510b83623e080a2c35c62c15298096e2a5dc8d51c3b4e1740211839d0dea77"},
+ {file = "cryptography-46.0.7-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:cbd5fb06b62bd0721e1170273d3f4d5a277044c47ca27ee257025146c34cbdd1"},
+ {file = "cryptography-46.0.7-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:420b1e4109cc95f0e5700eed79908cef9268265c773d3a66f7af1eef53d409ef"},
+ {file = "cryptography-46.0.7-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:24402210aa54baae71d99441d15bb5a1919c195398a87b563df84468160a65de"},
+ {file = "cryptography-46.0.7-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:8a469028a86f12eb7d2fe97162d0634026d92a21f3ae0ac87ed1c4a447886c83"},
+ {file = "cryptography-46.0.7-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:9694078c5d44c157ef3162e3bf3946510b857df5a3955458381d1c7cfc143ddb"},
+ {file = "cryptography-46.0.7-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:42a1e5f98abb6391717978baf9f90dc28a743b7d9be7f0751a6f56a75d14065b"},
+ {file = "cryptography-46.0.7-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:91bbcb08347344f810cbe49065914fe048949648f6bd5c2519f34619142bbe85"},
+ {file = "cryptography-46.0.7-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:5d1c02a14ceb9148cc7816249f64f623fbfee39e8c03b3650d842ad3f34d637e"},
+ {file = "cryptography-46.0.7-cp311-abi3-win32.whl", hash = "sha256:d23c8ca48e44ee015cd0a54aeccdf9f09004eba9fc96f38c911011d9ff1bd457"},
+ {file = "cryptography-46.0.7-cp311-abi3-win_amd64.whl", hash = "sha256:397655da831414d165029da9bc483bed2fe0e75dde6a1523ec2fe63f3c46046b"},
+ {file = "cryptography-46.0.7-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:d151173275e1728cf7839aaa80c34fe550c04ddb27b34f48c232193df8db5842"},
+ {file = "cryptography-46.0.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:db0f493b9181c7820c8134437eb8b0b4792085d37dbb24da050476ccb664e59c"},
+ {file = "cryptography-46.0.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ebd6daf519b9f189f85c479427bbd6e9c9037862cf8fe89ee35503bd209ed902"},
+ {file = "cryptography-46.0.7-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:b7b412817be92117ec5ed95f880defe9cf18a832e8cafacf0a22337dc1981b4d"},
+ {file = "cryptography-46.0.7-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:fbfd0e5f273877695cb93baf14b185f4878128b250cc9f8e617ea0c025dfb022"},
+ {file = "cryptography-46.0.7-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:ffca7aa1d00cf7d6469b988c581598f2259e46215e0140af408966a24cf086ce"},
+ {file = "cryptography-46.0.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:60627cf07e0d9274338521205899337c5d18249db56865f943cbe753aa96f40f"},
+ {file = "cryptography-46.0.7-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:80406c3065e2c55d7f49a9550fe0c49b3f12e5bfff5dedb727e319e1afb9bf99"},
+ {file = "cryptography-46.0.7-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:c5b1ccd1239f48b7151a65bc6dd54bcfcc15e028c8ac126d3fada09db0e07ef1"},
+ {file = "cryptography-46.0.7-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:d5f7520159cd9c2154eb61eb67548ca05c5774d39e9c2c4339fd793fe7d097b2"},
+ {file = "cryptography-46.0.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fcd8eac50d9138c1d7fc53a653ba60a2bee81a505f9f8850b6b2888555a45d0e"},
+ {file = "cryptography-46.0.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:65814c60f8cc400c63131584e3e1fad01235edba2614b61fbfbfa954082db0ee"},
+ {file = "cryptography-46.0.7-cp314-cp314t-win32.whl", hash = "sha256:fdd1736fed309b4300346f88f74cd120c27c56852c3838cab416e7a166f67298"},
+ {file = "cryptography-46.0.7-cp314-cp314t-win_amd64.whl", hash = "sha256:e06acf3c99be55aa3b516397fe42f5855597f430add9c17fa46bf2e0fb34c9bb"},
+ {file = "cryptography-46.0.7-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:462ad5cb1c148a22b2e3bcc5ad52504dff325d17daf5df8d88c17dda1f75f2a4"},
+ {file = "cryptography-46.0.7-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:84d4cced91f0f159a7ddacad249cc077e63195c36aac40b4150e7a57e84fffe7"},
+ {file = "cryptography-46.0.7-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:128c5edfe5e5938b86b03941e94fac9ee793a94452ad1365c9fc3f4f62216832"},
+ {file = "cryptography-46.0.7-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:5e51be372b26ef4ba3de3c167cd3d1022934bc838ae9eaad7e644986d2a3d163"},
+ {file = "cryptography-46.0.7-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:cdf1a610ef82abb396451862739e3fc93b071c844399e15b90726ef7470eeaf2"},
+ {file = "cryptography-46.0.7-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1d25aee46d0c6f1a501adcddb2d2fee4b979381346a78558ed13e50aa8a59067"},
+ {file = "cryptography-46.0.7-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:cdfbe22376065ffcf8be74dc9a909f032df19bc58a699456a21712d6e5eabfd0"},
+ {file = "cryptography-46.0.7-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:abad9dac36cbf55de6eb49badd4016806b3165d396f64925bf2999bcb67837ba"},
+ {file = "cryptography-46.0.7-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:935ce7e3cfdb53e3536119a542b839bb94ec1ad081013e9ab9b7cfd478b05006"},
+ {file = "cryptography-46.0.7-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:35719dc79d4730d30f1c2b6474bd6acda36ae2dfae1e3c16f2051f215df33ce0"},
+ {file = "cryptography-46.0.7-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7bbc6ccf49d05ac8f7d7b5e2e2c33830d4fe2061def88210a126d130d7f71a85"},
+ {file = "cryptography-46.0.7-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a1529d614f44b863a7b480c6d000fe93b59acee9c82ffa027cfadc77521a9f5e"},
+ {file = "cryptography-46.0.7-cp38-abi3-win32.whl", hash = "sha256:f247c8c1a1fb45e12586afbb436ef21ff1e80670b2861a90353d9b025583d246"},
+ {file = "cryptography-46.0.7-cp38-abi3-win_amd64.whl", hash = "sha256:506c4ff91eff4f82bdac7633318a526b1d1309fc07ca76a3ad182cb5b686d6d3"},
+ {file = "cryptography-46.0.7-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:fc9ab8856ae6cf7c9358430e49b368f3108f050031442eaeb6b9d87e4dcf4e4f"},
+ {file = "cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:d3b99c535a9de0adced13d159c5a9cf65c325601aa30f4be08afd680643e9c15"},
+ {file = "cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:d02c738dacda7dc2a74d1b2b3177042009d5cab7c7079db74afc19e56ca1b455"},
+ {file = "cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:04959522f938493042d595a736e7dbdff6eb6cc2339c11465b3ff89343b65f65"},
+ {file = "cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:3986ac1dee6def53797289999eabe84798ad7817f3e97779b5061a95b0ee4968"},
+ {file = "cryptography-46.0.7-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:258514877e15963bd43b558917bc9f54cf7cf866c38aa576ebf47a77ddbc43a4"},
+ {file = "cryptography-46.0.7.tar.gz", hash = "sha256:e4cfd68c5f3e0bfdad0d38e023239b96a2fe84146481852dffbcca442c245aa5"},
+]
+markers = {dev = "platform_machine != \"ppc64le\" and platform_machine != \"s390x\" and sys_platform == \"linux\""}
+
+[package.dependencies]
+cffi = {version = ">=2.0.0", markers = "python_full_version >= \"3.9.0\" and platform_python_implementation != \"PyPy\""}
+
+[package.extras]
+docs = ["sphinx (>=5.3.0)", "sphinx-inline-tabs", "sphinx-rtd-theme (>=3.0.0)"]
+docstest = ["pyenchant (>=3)", "readme-renderer (>=30.0)", "sphinxcontrib-spelling (>=7.3.1)"]
+nox = ["nox[uv] (>=2024.4.15)"]
+pep8test = ["check-sdist", "click (>=8.0.1)", "mypy (>=1.14)", "ruff (>=0.11.11)"]
+sdist = ["build (>=1.0.0)"]
+ssh = ["bcrypt (>=3.1.5)"]
+test = ["certifi (>=2024)", "cryptography-vectors (==46.0.7)", "pretend (>=0.7)", "pytest (>=7.4.0)", "pytest-benchmark (>=4.0)", "pytest-cov (>=2.10.1)", "pytest-xdist (>=3.5.0)"]
+test-randomorder = ["pytest-randomly"]
+
+[[package]]
+name = "docutils"
+version = "0.22.4"
+description = "Docutils -- Python Documentation Utilities"
+optional = false
+python-versions = ">=3.9"
+groups = ["dev"]
+files = [
+ {file = "docutils-0.22.4-py3-none-any.whl", hash = "sha256:d0013f540772d1420576855455d050a2180186c91c15779301ac2ccb3eeb68de"},
+ {file = "docutils-0.22.4.tar.gz", hash = "sha256:4db53b1fde9abecbb74d91230d32ab626d94f6badfc575d6db9194a49df29968"},
+]
+
+[[package]]
+name = "hypothesis"
+version = "6.152.1"
+description = "The property-based testing library for Python"
+optional = false
+python-versions = ">=3.10"
+groups = ["dev"]
+files = [
+ {file = "hypothesis-6.152.1-py3-none-any.whl", hash = "sha256:40a3619d9e0cb97b018857c7986f75cf5de2e5ec0fa8a0b172d00747758f749e"},
+ {file = "hypothesis-6.152.1.tar.gz", hash = "sha256:4f4ed934eee295dd84ee97592477d23e8dc03e9f12ae0ee30a4e7c9ef3fca3b0"},
+]
+
+[package.dependencies]
+sortedcontainers = ">=2.1.0,<3.0.0"
+
+[package.extras]
+all = ["black (>=20.8b0)", "click (>=7.0)", "crosshair-tool (>=0.0.102)", "django (>=4.2)", "dpcontracts (>=0.4)", "hypothesis-crosshair (>=0.0.27)", "lark (>=0.10.1)", "libcst (>=0.3.16)", "numpy (>=1.21.6)", "pandas (>=1.1)", "pytest (>=4.6)", "python-dateutil (>=1.4)", "pytz (>=2014.1)", "redis (>=3.0.0)", "rich (>=9.0.0)", "tzdata (>=2026.1) ; sys_platform == \"win32\" or sys_platform == \"emscripten\"", "watchdog (>=4.0.0)"]
+cli = ["black (>=20.8b0)", "click (>=7.0)", "rich (>=9.0.0)"]
+codemods = ["libcst (>=0.3.16)"]
+crosshair = ["crosshair-tool (>=0.0.102)", "hypothesis-crosshair (>=0.0.27)"]
+dateutil = ["python-dateutil (>=1.4)"]
+django = ["django (>=4.2)"]
+dpcontracts = ["dpcontracts (>=0.4)"]
+ghostwriter = ["black (>=20.8b0)"]
+lark = ["lark (>=0.10.1)"]
+numpy = ["numpy (>=1.21.6)"]
+pandas = ["pandas (>=1.1)"]
+pytest = ["pytest (>=4.6)"]
+pytz = ["pytz (>=2014.1)"]
+redis = ["redis (>=3.0.0)"]
+watchdog = ["watchdog (>=4.0.0)"]
+zoneinfo = ["tzdata (>=2026.1) ; sys_platform == \"win32\" or sys_platform == \"emscripten\""]
+
+[[package]]
+name = "id"
+version = "1.6.1"
+description = "A tool for generating OIDC identities"
+optional = false
+python-versions = ">=3.9"
+groups = ["dev"]
+files = [
+ {file = "id-1.6.1-py3-none-any.whl", hash = "sha256:f5ec41ed2629a508f5d0988eda142e190c9c6da971100612c4de9ad9f9b237ca"},
+ {file = "id-1.6.1.tar.gz", hash = "sha256:d0732d624fb46fd4e7bc4e5152f00214450953b9e772c182c1c22964def1a069"},
+]
+
+[package.dependencies]
+urllib3 = ">=2,<3"
+
+[package.extras]
+dev = ["build", "bump (>=1.3.2)", "id[lint,test]"]
+lint = ["bandit", "interrogate", "mypy", "ruff (<0.14.15)"]
+test = ["coverage[toml]", "pretend", "pytest", "pytest-cov"]
+
+[[package]]
+name = "idna"
+version = "3.11"
+description = "Internationalized Domain Names in Applications (IDNA)"
+optional = false
+python-versions = ">=3.8"
+groups = ["dev"]
+files = [
+ {file = "idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea"},
+ {file = "idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902"},
+]
+
+[package.extras]
+all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"]
+
+[[package]]
+name = "importlib-metadata"
+version = "9.0.0"
+description = "Read metadata from Python packages"
+optional = false
+python-versions = ">=3.10"
+groups = ["dev"]
+markers = "platform_machine != \"ppc64le\" and platform_machine != \"s390x\" and python_version == \"3.11\""
+files = [
+ {file = "importlib_metadata-9.0.0-py3-none-any.whl", hash = "sha256:2d21d1cc5a017bd0559e36150c21c830ab1dc304dedd1b7ea85d20f45ef3edd7"},
+ {file = "importlib_metadata-9.0.0.tar.gz", hash = "sha256:a4f57ab599e6a2e3016d7595cfd72eb4661a5106e787a95bcc90c7105b831efc"},
+]
+
+[package.dependencies]
+zipp = ">=3.20"
+
+[package.extras]
+check = ["pytest-checkdocs (>=2.14)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\""]
+cover = ["pytest-cov"]
+doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"]
+enabler = ["pytest-enabler (>=3.4)"]
+perf = ["ipython"]
+test = ["packaging", "pyfakefs", "pytest (>=6,!=8.1.*)", "pytest-perf (>=0.9.2)"]
+type = ["pytest-mypy (>=1.0.1) ; platform_python_implementation != \"PyPy\""]
+
+[[package]]
+name = "iniconfig"
+version = "2.3.0"
+description = "brain-dead simple config-ini parsing"
+optional = false
+python-versions = ">=3.10"
+groups = ["dev"]
+files = [
+ {file = "iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12"},
+ {file = "iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730"},
+]
+
+[[package]]
+name = "jaraco-classes"
+version = "3.4.0"
+description = "Utility functions for Python class constructs"
+optional = false
+python-versions = ">=3.8"
+groups = ["dev"]
+markers = "platform_machine != \"ppc64le\" and platform_machine != \"s390x\""
+files = [
+ {file = "jaraco.classes-3.4.0-py3-none-any.whl", hash = "sha256:f662826b6bed8cace05e7ff873ce0f9283b5c924470fe664fff1c2f00f581790"},
+ {file = "jaraco.classes-3.4.0.tar.gz", hash = "sha256:47a024b51d0239c0dd8c8540c6c7f484be3b8fcf0b2d85c13825780d3b3f3acd"},
+]
+
+[package.dependencies]
+more-itertools = "*"
+
+[package.extras]
+docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"]
+testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-mypy", "pytest-ruff (>=0.2.1)"]
+
+[[package]]
+name = "jaraco-context"
+version = "6.1.2"
+description = "Useful decorators and context managers"
+optional = false
+python-versions = ">=3.10"
+groups = ["dev"]
+markers = "platform_machine != \"ppc64le\" and platform_machine != \"s390x\""
+files = [
+ {file = "jaraco_context-6.1.2-py3-none-any.whl", hash = "sha256:bf8150b79a2d5d91ae48629d8b427a8f7ba0e1097dd6202a9059f29a36379535"},
+ {file = "jaraco_context-6.1.2.tar.gz", hash = "sha256:f1a6c9d391e661cc5b8d39861ff077a7dc24dc23833ccee564b234b81c82dfe3"},
+]
+
+[package.dependencies]
+"backports.tarfile" = {version = "*", markers = "python_version < \"3.12\""}
+
+[package.extras]
+check = ["pytest-checkdocs (>=2.14)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\""]
+cover = ["pytest-cov"]
+doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"]
+enabler = ["pytest-enabler (>=3.4)"]
+test = ["jaraco.test (>=5.6.0)", "portend", "pytest (>=6,!=8.1.*)"]
+type = ["pytest-mypy (>=1.0.1) ; platform_python_implementation != \"PyPy\""]
+
+[[package]]
+name = "jaraco-functools"
+version = "4.4.0"
+description = "Functools like those found in stdlib"
+optional = false
+python-versions = ">=3.9"
+groups = ["dev"]
+markers = "platform_machine != \"ppc64le\" and platform_machine != \"s390x\""
+files = [
+ {file = "jaraco_functools-4.4.0-py3-none-any.whl", hash = "sha256:9eec1e36f45c818d9bf307c8948eb03b2b56cd44087b3cdc989abca1f20b9176"},
+ {file = "jaraco_functools-4.4.0.tar.gz", hash = "sha256:da21933b0417b89515562656547a77b4931f98176eb173644c0d35032a33d6bb"},
+]
+
+[package.dependencies]
+more_itertools = "*"
+
+[package.extras]
+check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\""]
+cover = ["pytest-cov"]
+doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"]
+enabler = ["pytest-enabler (>=3.4)"]
+test = ["jaraco.classes", "pytest (>=6,!=8.1.*)"]
+type = ["mypy (<1.19) ; platform_python_implementation == \"PyPy\"", "pytest-mypy (>=1.0.1)"]
+
+[[package]]
+name = "jeepney"
+version = "0.9.0"
+description = "Low-level, pure Python DBus protocol wrapper."
+optional = false
+python-versions = ">=3.7"
+groups = ["dev"]
+markers = "platform_machine != \"ppc64le\" and platform_machine != \"s390x\" and sys_platform == \"linux\""
+files = [
+ {file = "jeepney-0.9.0-py3-none-any.whl", hash = "sha256:97e5714520c16fc0a45695e5365a2e11b81ea79bba796e26f9f1d178cb182683"},
+ {file = "jeepney-0.9.0.tar.gz", hash = "sha256:cf0e9e845622b81e4a28df94c40345400256ec608d0e55bb8a3feaa9163f5732"},
+]
+
+[package.extras]
+test = ["async-timeout ; python_version < \"3.11\"", "pytest", "pytest-asyncio (>=0.17)", "pytest-trio", "testpath", "trio"]
+trio = ["trio"]
+
+[[package]]
+name = "keyring"
+version = "25.7.0"
+description = "Store and access your passwords safely."
+optional = false
+python-versions = ">=3.9"
+groups = ["dev"]
+markers = "platform_machine != \"ppc64le\" and platform_machine != \"s390x\""
+files = [
+ {file = "keyring-25.7.0-py3-none-any.whl", hash = "sha256:be4a0b195f149690c166e850609a477c532ddbfbaed96a404d4e43f8d5e2689f"},
+ {file = "keyring-25.7.0.tar.gz", hash = "sha256:fe01bd85eb3f8fb3dd0405defdeac9a5b4f6f0439edbb3149577f244a2e8245b"},
+]
+
+[package.dependencies]
+importlib_metadata = {version = ">=4.11.4", markers = "python_version < \"3.12\""}
+"jaraco.classes" = "*"
+"jaraco.context" = "*"
+"jaraco.functools" = "*"
+jeepney = {version = ">=0.4.2", markers = "sys_platform == \"linux\""}
+pywin32-ctypes = {version = ">=0.2.0", markers = "sys_platform == \"win32\""}
+SecretStorage = {version = ">=3.2", markers = "sys_platform == \"linux\""}
+
+[package.extras]
+check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\""]
+completion = ["shtab (>=1.1.0)"]
+cover = ["pytest-cov"]
+doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"]
+enabler = ["pytest-enabler (>=3.4)"]
+test = ["pyfakefs", "pytest (>=6,!=8.1.*)"]
+type = ["pygobject-stubs", "pytest-mypy (>=1.0.1)", "shtab", "types-pywin32"]
+
+[[package]]
+name = "lxmf"
+version = "0.9.6"
+description = "Lightweight Extensible Message Format for Reticulum"
+optional = false
+python-versions = ">=3.7"
+groups = ["main"]
+files = [
+ {file = "lxmf-0.9.6-py3-none-any.whl", hash = "sha256:67e8e8b34d1756ea229c59bee08b382e57df95a047b55321ba5ab180d2eafe6e"},
+]
+
+[package.dependencies]
+rns = ">=1.1.9"
+
+[[package]]
+name = "markdown-it-py"
+version = "4.0.0"
+description = "Python port of markdown-it. Markdown parsing, done right!"
+optional = false
+python-versions = ">=3.10"
+groups = ["dev"]
+files = [
+ {file = "markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147"},
+ {file = "markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3"},
+]
+
+[package.dependencies]
+mdurl = ">=0.1,<1.0"
+
+[package.extras]
+benchmarking = ["psutil", "pytest", "pytest-benchmark"]
+compare = ["commonmark (>=0.9,<1.0)", "markdown (>=3.4,<4.0)", "markdown-it-pyrs", "mistletoe (>=1.0,<2.0)", "mistune (>=3.0,<4.0)", "panflute (>=2.3,<3.0)"]
+linkify = ["linkify-it-py (>=1,<3)"]
+plugins = ["mdit-py-plugins (>=0.5.0)"]
+profiling = ["gprof2dot"]
+rtd = ["ipykernel", "jupyter_sphinx", "mdit-py-plugins (>=0.5.0)", "myst-parser", "pyyaml", "sphinx", "sphinx-book-theme (>=1.0,<2.0)", "sphinx-copybutton", "sphinx-design"]
+testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions", "requests"]
+
+[[package]]
+name = "mdurl"
+version = "0.1.2"
+description = "Markdown URL utilities"
+optional = false
+python-versions = ">=3.7"
+groups = ["dev"]
+files = [
+ {file = "mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8"},
+ {file = "mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba"},
+]
+
+[[package]]
+name = "more-itertools"
+version = "11.0.2"
+description = "More routines for operating on iterables, beyond itertools"
+optional = false
+python-versions = ">=3.10"
+groups = ["dev"]
+markers = "platform_machine != \"ppc64le\" and platform_machine != \"s390x\""
+files = [
+ {file = "more_itertools-11.0.2-py3-none-any.whl", hash = "sha256:6e35b35f818b01f691643c6c611bc0902f2e92b46c18fffa77ae1e7c46e912e4"},
+ {file = "more_itertools-11.0.2.tar.gz", hash = "sha256:392a9e1e362cbc106a2457d37cabf9b36e5e12efd4ebff1654630e76597df804"},
+]
+
+[[package]]
+name = "nh3"
+version = "0.3.4"
+description = "Python binding to Ammonia HTML sanitizer Rust crate"
+optional = false
+python-versions = ">=3.8"
+groups = ["dev"]
+files = [
+ {file = "nh3-0.3.4-cp314-cp314t-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:8b61058f34c2105d44d2a4d4241bacf603a1ef5c143b08766bbd0cf23830118f"},
+ {file = "nh3-0.3.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:554cc2bab281758e94d770c3fb0bf2d8be5fb403ef6b2e8841dd7c1615df7a0f"},
+ {file = "nh3-0.3.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:dbe76feaa44e2ef9436f345016012a591550e77818876a8de5c8bc2a248e08df"},
+ {file = "nh3-0.3.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:87dac8d611b4a478400e0821a13b35770e88c266582f065e7249d6a37b0f86e8"},
+ {file = "nh3-0.3.4-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:8d697e19f2995b337f648204848ac3a528eaafffc39e7ce4ac6b7a2fbe6c84af"},
+ {file = "nh3-0.3.4-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:7cae217f031809321db962cd7e092bda8d4e95a87f78c0226628fa6c2ea8ebc5"},
+ {file = "nh3-0.3.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:07999b998bf89692738f15c0eac76a416382932f855709e0b7488b595c30ec89"},
+ {file = "nh3-0.3.4-cp314-cp314t-win32.whl", hash = "sha256:ca90397c8d36c1535bf1988b2bed006597337843a164c7ec269dc8813f37536b"},
+ {file = "nh3-0.3.4-cp314-cp314t-win_amd64.whl", hash = "sha256:41e46b3499918ab6128b6421677b316e79869d0c140da24069d220a94f4e72d1"},
+ {file = "nh3-0.3.4-cp314-cp314t-win_arm64.whl", hash = "sha256:80b955d802bf365bd42e09f6c3d64567dce777d20e97968d94b3e9d9e99b265e"},
+ {file = "nh3-0.3.4-cp38-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:d8bebcb20ab4b91858385cd98fe58046ec4a624275b45ef9b976475604f45b49"},
+ {file = "nh3-0.3.4-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0d825722a1e8cbc87d7ca1e47ffb1d2a6cf343ad4c1b8465becf7cadcabcdfd0"},
+ {file = "nh3-0.3.4-cp38-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4aa8b43e68c26b68069a3b6cef09de166d1d7fa140cf8d77e409a46cbf742e44"},
+ {file = "nh3-0.3.4-cp38-abi3-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:f5f214618ad5eff4f2a6b13a8d4da4d9e7f37c569d90a13fb9f0caaf7d04fe21"},
+ {file = "nh3-0.3.4-cp38-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3390e4333883673a684ce16c1716b481e91782d6f56dec5c85fed9feedb23382"},
+ {file = "nh3-0.3.4-cp38-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:18a2e44ccb29cbb45071b8f3f2dab9ebfb41a6516f328f91f1f1fd18196239a4"},
+ {file = "nh3-0.3.4-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0961a27dc2057c38d0364cb05880e1997ae1c80220cbc847db63213720b8f304"},
+ {file = "nh3-0.3.4-cp38-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:9337517edb7c10228252cce2898e20fb3d77e32ffaccbb3c66897927d74215a0"},
+ {file = "nh3-0.3.4-cp38-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d866701affe67a5171b916b5c076e767a74c6a9efb7fb2006eb8d3c5f9a293d5"},
+ {file = "nh3-0.3.4-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:47d749d99ae005ab19517224140b280dd56e77b33afb82f9b600e106d0458003"},
+ {file = "nh3-0.3.4-cp38-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:f987cb56458323405e8e5ea827e1befcf141ffa0c0ac797d6d02e6b646056d9a"},
+ {file = "nh3-0.3.4-cp38-abi3-musllinux_1_2_i686.whl", hash = "sha256:883d5a6d6ee8078c4afc8e96e022fe579c4c265775ff6ee21e39b8c542cabab3"},
+ {file = "nh3-0.3.4-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:75643c22f5092d8e209f766ee8108c400bc1e44760fc94d2d638eb138d18f853"},
+ {file = "nh3-0.3.4-cp38-abi3-win32.whl", hash = "sha256:72e4e9ca1c4bd41b4a28b0190edc2e21e3f71496acd36a0162858e1a28db3d7e"},
+ {file = "nh3-0.3.4-cp38-abi3-win_amd64.whl", hash = "sha256:c10b1f0c741e257a5cb2978d6bac86e7c784ab20572724b20c6402c2e24bce75"},
+ {file = "nh3-0.3.4-cp38-abi3-win_arm64.whl", hash = "sha256:43ad4eedee7e049b9069bc015b7b095d320ed6d167ecec111f877de1540656e9"},
+ {file = "nh3-0.3.4.tar.gz", hash = "sha256:96709a379997c1b28c8974146ca660b0dcd3794f4f6d50c1ea549bab39ac6ade"},
+]
+
+[[package]]
+name = "packaging"
+version = "26.1"
+description = "Core utilities for Python packages"
+optional = false
+python-versions = ">=3.8"
+groups = ["dev"]
+files = [
+ {file = "packaging-26.1-py3-none-any.whl", hash = "sha256:5d9c0669c6285e491e0ced2eee587eaf67b670d94a19e94e3984a481aba6802f"},
+ {file = "packaging-26.1.tar.gz", hash = "sha256:f042152b681c4bfac5cae2742a55e103d27ab2ec0f3d88037136b6bfe7c9c5de"},
+]
+
+[[package]]
+name = "pluggy"
+version = "1.6.0"
+description = "plugin and hook calling mechanisms for python"
+optional = false
+python-versions = ">=3.9"
+groups = ["dev"]
+files = [
+ {file = "pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746"},
+ {file = "pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3"},
+]
+
+[package.extras]
+dev = ["pre-commit", "tox"]
+testing = ["coverage", "pytest", "pytest-benchmark"]
+
+[[package]]
+name = "psutil"
+version = "7.2.2"
+description = "Cross-platform lib for process and system monitoring."
+optional = false
+python-versions = ">=3.6"
+groups = ["dev"]
+files = [
+ {file = "psutil-7.2.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:2edccc433cbfa046b980b0df0171cd25bcaeb3a68fe9022db0979e7aa74a826b"},
+ {file = "psutil-7.2.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e78c8603dcd9a04c7364f1a3e670cea95d51ee865e4efb3556a3a63adef958ea"},
+ {file = "psutil-7.2.2-cp313-cp313t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1a571f2330c966c62aeda00dd24620425d4b0cc86881c89861fbc04549e5dc63"},
+ {file = "psutil-7.2.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:917e891983ca3c1887b4ef36447b1e0873e70c933afc831c6b6da078ba474312"},
+ {file = "psutil-7.2.2-cp313-cp313t-win_amd64.whl", hash = "sha256:ab486563df44c17f5173621c7b198955bd6b613fb87c71c161f827d3fb149a9b"},
+ {file = "psutil-7.2.2-cp313-cp313t-win_arm64.whl", hash = "sha256:ae0aefdd8796a7737eccea863f80f81e468a1e4cf14d926bd9b6f5f2d5f90ca9"},
+ {file = "psutil-7.2.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:eed63d3b4d62449571547b60578c5b2c4bcccc5387148db46e0c2313dad0ee00"},
+ {file = "psutil-7.2.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7b6d09433a10592ce39b13d7be5a54fbac1d1228ed29abc880fb23df7cb694c9"},
+ {file = "psutil-7.2.2-cp314-cp314t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fa4ecf83bcdf6e6c8f4449aff98eefb5d0604bf88cb883d7da3d8d2d909546a"},
+ {file = "psutil-7.2.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e452c464a02e7dc7822a05d25db4cde564444a67e58539a00f929c51eddda0cf"},
+ {file = "psutil-7.2.2-cp314-cp314t-win_amd64.whl", hash = "sha256:c7663d4e37f13e884d13994247449e9f8f574bc4655d509c3b95e9ec9e2b9dc1"},
+ {file = "psutil-7.2.2-cp314-cp314t-win_arm64.whl", hash = "sha256:11fe5a4f613759764e79c65cf11ebdf26e33d6dd34336f8a337aa2996d71c841"},
+ {file = "psutil-7.2.2-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:ed0cace939114f62738d808fdcecd4c869222507e266e574799e9c0faa17d486"},
+ {file = "psutil-7.2.2-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:1a7b04c10f32cc88ab39cbf606e117fd74721c831c98a27dc04578deb0c16979"},
+ {file = "psutil-7.2.2-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:076a2d2f923fd4821644f5ba89f059523da90dc9014e85f8e45a5774ca5bc6f9"},
+ {file = "psutil-7.2.2-cp36-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b0726cecd84f9474419d67252add4ac0cd9811b04d61123054b9fb6f57df6e9e"},
+ {file = "psutil-7.2.2-cp36-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd04ef36b4a6d599bbdb225dd1d3f51e00105f6d48a28f006da7f9822f2606d8"},
+ {file = "psutil-7.2.2-cp36-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b58fabe35e80b264a4e3bb23e6b96f9e45a3df7fb7eed419ac0e5947c61e47cc"},
+ {file = "psutil-7.2.2-cp37-abi3-win_amd64.whl", hash = "sha256:eb7e81434c8d223ec4a219b5fc1c47d0417b12be7ea866e24fb5ad6e84b3d988"},
+ {file = "psutil-7.2.2-cp37-abi3-win_arm64.whl", hash = "sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee"},
+ {file = "psutil-7.2.2.tar.gz", hash = "sha256:0746f5f8d406af344fd547f1c8daa5f5c33dbc293bb8d6a16d80b4bb88f59372"},
+]
+
+[package.extras]
+dev = ["abi3audit", "black", "check-manifest", "colorama ; os_name == \"nt\"", "coverage", "packaging", "psleak", "pylint", "pyperf", "pypinfo", "pyreadline3 ; os_name == \"nt\"", "pytest", "pytest-cov", "pytest-instafail", "pytest-xdist", "pywin32 ; os_name == \"nt\" and implementation_name != \"pypy\"", "requests", "rstcheck", "ruff", "setuptools", "sphinx", "sphinx_rtd_theme", "toml-sort", "twine", "validate-pyproject[all]", "virtualenv", "vulture", "wheel", "wheel ; os_name == \"nt\" and implementation_name != \"pypy\"", "wmi ; os_name == \"nt\" and implementation_name != \"pypy\""]
+test = ["psleak", "pytest", "pytest-instafail", "pytest-xdist", "pywin32 ; os_name == \"nt\" and implementation_name != \"pypy\"", "setuptools", "wheel ; os_name == \"nt\" and implementation_name != \"pypy\"", "wmi ; os_name == \"nt\" and implementation_name != \"pypy\""]
+
+[[package]]
+name = "pycparser"
+version = "3.0"
+description = "C parser in Python"
+optional = false
+python-versions = ">=3.10"
+groups = ["main", "dev"]
+files = [
+ {file = "pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992"},
+ {file = "pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29"},
+]
+markers = {main = "platform_python_implementation != \"PyPy\" and implementation_name != \"PyPy\"", dev = "platform_machine != \"ppc64le\" and platform_machine != \"s390x\" and sys_platform == \"linux\" and platform_python_implementation != \"PyPy\" and implementation_name != \"PyPy\""}
+
+[[package]]
+name = "pygments"
+version = "2.20.0"
+description = "Pygments is a syntax highlighting package written in Python."
+optional = false
+python-versions = ">=3.9"
+groups = ["dev"]
+files = [
+ {file = "pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176"},
+ {file = "pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f"},
+]
+
+[package.extras]
+windows-terminal = ["colorama (>=0.4.6)"]
+
+[[package]]
+name = "pyserial"
+version = "3.5"
+description = "Python Serial Port Extension"
+optional = false
+python-versions = "*"
+groups = ["main"]
+files = [
+ {file = "pyserial-3.5-py2.py3-none-any.whl", hash = "sha256:c4451db6ba391ca6ca299fb3ec7bae67a5c55dde170964c7a14ceefec02f2cf0"},
+ {file = "pyserial-3.5.tar.gz", hash = "sha256:3c77e014170dfffbd816e6ffc205e9842efb10be9f58ec16d3e8675b4925cddb"},
+]
+
+[package.extras]
+cp2110 = ["hidapi"]
+
+[[package]]
+name = "pytest"
+version = "8.4.2"
+description = "pytest: simple powerful testing with Python"
+optional = false
+python-versions = ">=3.9"
+groups = ["dev"]
+files = [
+ {file = "pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79"},
+ {file = "pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01"},
+]
+
+[package.dependencies]
+colorama = {version = ">=0.4", markers = "sys_platform == \"win32\""}
+iniconfig = ">=1"
+packaging = ">=20"
+pluggy = ">=1.5,<2"
+pygments = ">=2.7.2"
+
+[package.extras]
+dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "requests", "setuptools", "xmlschema"]
+
+[[package]]
+name = "pytest-asyncio"
+version = "1.3.0"
+description = "Pytest support for asyncio"
+optional = false
+python-versions = ">=3.10"
+groups = ["dev"]
+files = [
+ {file = "pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5"},
+ {file = "pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5"},
+]
+
+[package.dependencies]
+pytest = ">=8.2,<10"
+typing-extensions = {version = ">=4.12", markers = "python_version < \"3.13\""}
+
+[package.extras]
+docs = ["sphinx (>=5.3)", "sphinx-rtd-theme (>=1)"]
+testing = ["coverage (>=6.2)", "hypothesis (>=5.7.1)"]
+
+[[package]]
+name = "pytest-cov"
+version = "7.1.0"
+description = "Pytest plugin for measuring coverage."
+optional = false
+python-versions = ">=3.9"
+groups = ["dev"]
+files = [
+ {file = "pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678"},
+ {file = "pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2"},
+]
+
+[package.dependencies]
+coverage = {version = ">=7.10.6", extras = ["toml"]}
+pluggy = ">=1.2"
+pytest = ">=7"
+
+[package.extras]
+testing = ["process-tests", "pytest-xdist", "virtualenv"]
+
+[[package]]
+name = "pywin32-ctypes"
+version = "0.2.3"
+description = "A (partial) reimplementation of pywin32 using ctypes/cffi"
+optional = false
+python-versions = ">=3.6"
+groups = ["dev"]
+markers = "platform_machine != \"ppc64le\" and platform_machine != \"s390x\" and sys_platform == \"win32\""
+files = [
+ {file = "pywin32-ctypes-0.2.3.tar.gz", hash = "sha256:d162dc04946d704503b2edc4d55f3dba5c1d539ead017afa00142c38b9885755"},
+ {file = "pywin32_ctypes-0.2.3-py3-none-any.whl", hash = "sha256:8a1513379d709975552d202d942d9837758905c8d01eb82b8bcc30918929e7b8"},
+]
+
+[[package]]
+name = "readme-renderer"
+version = "44.0"
+description = "readme_renderer is a library for rendering readme descriptions for Warehouse"
+optional = false
+python-versions = ">=3.9"
+groups = ["dev"]
+files = [
+ {file = "readme_renderer-44.0-py3-none-any.whl", hash = "sha256:2fbca89b81a08526aadf1357a8c2ae889ec05fb03f5da67f9769c9a592166151"},
+ {file = "readme_renderer-44.0.tar.gz", hash = "sha256:8712034eabbfa6805cacf1402b4eeb2a73028f72d1166d6f5cb7f9c047c5d1e1"},
+]
+
+[package.dependencies]
+docutils = ">=0.21.2"
+nh3 = ">=0.2.14"
+Pygments = ">=2.5.1"
+
+[package.extras]
+md = ["cmarkgfm (>=0.8.0)"]
+
+[[package]]
+name = "requests"
+version = "2.33.1"
+description = "Python HTTP for Humans."
+optional = false
+python-versions = ">=3.10"
+groups = ["dev"]
+files = [
+ {file = "requests-2.33.1-py3-none-any.whl", hash = "sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a"},
+ {file = "requests-2.33.1.tar.gz", hash = "sha256:18817f8c57c6263968bc123d237e3b8b08ac046f5456bd1e307ee8f4250d3517"},
+]
+
+[package.dependencies]
+certifi = ">=2023.5.7"
+charset_normalizer = ">=2,<4"
+idna = ">=2.5,<4"
+urllib3 = ">=1.26,<3"
+
+[package.extras]
+socks = ["PySocks (>=1.5.6,!=1.5.7)"]
+use-chardet-on-py3 = ["chardet (>=3.0.2,<8)"]
+
+[[package]]
+name = "requests-toolbelt"
+version = "1.0.0"
+description = "A utility belt for advanced users of python-requests"
+optional = false
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
+groups = ["dev"]
+files = [
+ {file = "requests-toolbelt-1.0.0.tar.gz", hash = "sha256:7681a0a3d047012b5bdc0ee37d7f8f07ebe76ab08caeccfc3921ce23c88d5bc6"},
+ {file = "requests_toolbelt-1.0.0-py2.py3-none-any.whl", hash = "sha256:cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06"},
+]
+
+[package.dependencies]
+requests = ">=2.0.1,<3.0.0"
+
+[[package]]
+name = "rfc3986"
+version = "2.0.0"
+description = "Validating URI References per RFC 3986"
+optional = false
+python-versions = ">=3.7"
+groups = ["dev"]
+files = [
+ {file = "rfc3986-2.0.0-py2.py3-none-any.whl", hash = "sha256:50b1502b60e289cb37883f3dfd34532b8873c7de9f49bb546641ce9cbd256ebd"},
+ {file = "rfc3986-2.0.0.tar.gz", hash = "sha256:97aacf9dbd4bfd829baad6e6309fa6573aaf1be3f6fa735c8ab05e46cecb261c"},
+]
+
+[package.extras]
+idna2008 = ["idna"]
+
+[[package]]
+name = "rich"
+version = "15.0.0"
+description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal"
+optional = false
+python-versions = ">=3.9.0"
+groups = ["dev"]
+files = [
+ {file = "rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb"},
+ {file = "rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36"},
+]
+
+[package.dependencies]
+markdown-it-py = ">=2.2.0"
+pygments = ">=2.13.0,<3.0.0"
+
+[package.extras]
+jupyter = ["ipywidgets (>=7.5.1,<9)"]
+
+[[package]]
+name = "rns"
+version = "1.2.0"
+description = "Self-configuring, encrypted and resilient mesh networking stack for LoRa, packet radio, WiFi and everything in between"
+optional = false
+python-versions = ">=3.7"
+groups = ["main"]
+files = [
+ {file = "rns-1.2.0-py3-none-any.whl", hash = "sha256:b58e97332241755ed32e309d46e09615a123490430ae85fcbdec9318c9e26154"},
+ {file = "rns-1.2.0.tar.gz", hash = "sha256:b34f1fc0339cfff2c53a3bc5a2420d79dbc6cd09077901828c0bc030f8da7b48"},
+]
+
+[package.dependencies]
+cryptography = ">=3.4.7"
+pyserial = ">=3.5"
+
+[[package]]
+name = "ruff"
+version = "0.14.14"
+description = "An extremely fast Python linter and code formatter, written in Rust."
+optional = false
+python-versions = ">=3.7"
+groups = ["dev"]
+files = [
+ {file = "ruff-0.14.14-py3-none-linux_armv6l.whl", hash = "sha256:7cfe36b56e8489dee8fbc777c61959f60ec0f1f11817e8f2415f429552846aed"},
+ {file = "ruff-0.14.14-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:6006a0082336e7920b9573ef8a7f52eec837add1265cc74e04ea8a4368cd704c"},
+ {file = "ruff-0.14.14-py3-none-macosx_11_0_arm64.whl", hash = "sha256:026c1d25996818f0bf498636686199d9bd0d9d6341c9c2c3b62e2a0198b758de"},
+ {file = "ruff-0.14.14-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f666445819d31210b71e0a6d1c01e24447a20b85458eea25a25fe8142210ae0e"},
+ {file = "ruff-0.14.14-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3c0f18b922c6d2ff9a5e6c3ee16259adc513ca775bcf82c67ebab7cbd9da5bc8"},
+ {file = "ruff-0.14.14-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1629e67489c2dea43e8658c3dba659edbfd87361624b4040d1df04c9740ae906"},
+ {file = "ruff-0.14.14-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:27493a2131ea0f899057d49d303e4292b2cae2bb57253c1ed1f256fbcd1da480"},
+ {file = "ruff-0.14.14-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:01ff589aab3f5b539e35db38425da31a57521efd1e4ad1ae08fc34dbe30bd7df"},
+ {file = "ruff-0.14.14-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1cc12d74eef0f29f51775f5b755913eb523546b88e2d733e1d701fe65144e89b"},
+ {file = "ruff-0.14.14-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bb8481604b7a9e75eff53772496201690ce2687067e038b3cc31aaf16aa0b974"},
+ {file = "ruff-0.14.14-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:14649acb1cf7b5d2d283ebd2f58d56b75836ed8c6f329664fa91cdea19e76e66"},
+ {file = "ruff-0.14.14-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:e8058d2145566510790eab4e2fad186002e288dec5e0d343a92fe7b0bc1b3e13"},
+ {file = "ruff-0.14.14-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:e651e977a79e4c758eb807f0481d673a67ffe53cfa92209781dfa3a996cf8412"},
+ {file = "ruff-0.14.14-py3-none-musllinux_1_2_i686.whl", hash = "sha256:cc8b22da8d9d6fdd844a68ae937e2a0adf9b16514e9a97cc60355e2d4b219fc3"},
+ {file = "ruff-0.14.14-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:16bc890fb4cc9781bb05beb5ab4cd51be9e7cb376bf1dd3580512b24eb3fda2b"},
+ {file = "ruff-0.14.14-py3-none-win32.whl", hash = "sha256:b530c191970b143375b6a68e6f743800b2b786bbcf03a7965b06c4bf04568167"},
+ {file = "ruff-0.14.14-py3-none-win_amd64.whl", hash = "sha256:3dde1435e6b6fe5b66506c1dff67a421d0b7f6488d466f651c07f4cab3bf20fd"},
+ {file = "ruff-0.14.14-py3-none-win_arm64.whl", hash = "sha256:56e6981a98b13a32236a72a8da421d7839221fa308b223b9283312312e5ac76c"},
+ {file = "ruff-0.14.14.tar.gz", hash = "sha256:2d0f819c9a90205f3a867dbbd0be083bee9912e170fd7d9704cc8ae45824896b"},
+]
+
+[[package]]
+name = "secretstorage"
+version = "3.5.0"
+description = "Python bindings to FreeDesktop.org Secret Service API"
+optional = false
+python-versions = ">=3.10"
+groups = ["dev"]
+markers = "platform_machine != \"ppc64le\" and platform_machine != \"s390x\" and sys_platform == \"linux\""
+files = [
+ {file = "secretstorage-3.5.0-py3-none-any.whl", hash = "sha256:0ce65888c0725fcb2c5bc0fdb8e5438eece02c523557ea40ce0703c266248137"},
+ {file = "secretstorage-3.5.0.tar.gz", hash = "sha256:f04b8e4689cbce351744d5537bf6b1329c6fc68f91fa666f60a380edddcd11be"},
+]
+
+[package.dependencies]
+cryptography = ">=2.0"
+jeepney = ">=0.6"
+
+[[package]]
+name = "sortedcontainers"
+version = "2.4.0"
+description = "Sorted Containers -- Sorted List, Sorted Dict, Sorted Set"
+optional = false
+python-versions = "*"
+groups = ["dev"]
+files = [
+ {file = "sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0"},
+ {file = "sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88"},
+]
+
+[[package]]
+name = "twine"
+version = "6.2.0"
+description = "Collection of utilities for publishing packages on PyPI"
+optional = false
+python-versions = ">=3.9"
+groups = ["dev"]
+files = [
+ {file = "twine-6.2.0-py3-none-any.whl", hash = "sha256:418ebf08ccda9a8caaebe414433b0ba5e25eb5e4a927667122fbe8f829f985d8"},
+ {file = "twine-6.2.0.tar.gz", hash = "sha256:e5ed0d2fd70c9959770dce51c8f39c8945c574e18173a7b81802dab51b4b75cf"},
+]
+
+[package.dependencies]
+id = "*"
+keyring = {version = ">=21.2.0", markers = "platform_machine != \"ppc64le\" and platform_machine != \"s390x\""}
+packaging = ">=24.0"
+readme-renderer = ">=35.0"
+requests = ">=2.20"
+requests-toolbelt = ">=0.8.0,<0.9.0 || >0.9.0"
+rfc3986 = ">=1.4.0"
+rich = ">=12.0.0"
+urllib3 = ">=1.26.0"
+
+[package.extras]
+keyring = ["keyring (>=21.2.0)"]
+
+[[package]]
+name = "typing-extensions"
+version = "4.15.0"
+description = "Backported and Experimental Type Hints for Python 3.9+"
+optional = false
+python-versions = ">=3.9"
+groups = ["dev"]
+markers = "python_version < \"3.13\""
+files = [
+ {file = "typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"},
+ {file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"},
+]
+
+[[package]]
+name = "urllib3"
+version = "2.6.3"
+description = "HTTP library with thread-safe connection pooling, file post, and more."
+optional = false
+python-versions = ">=3.9"
+groups = ["dev"]
+files = [
+ {file = "urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4"},
+ {file = "urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed"},
+]
+
+[package.extras]
+brotli = ["brotli (>=1.2.0) ; platform_python_implementation == \"CPython\"", "brotlicffi (>=1.2.0.0) ; platform_python_implementation != \"CPython\""]
+h2 = ["h2 (>=4,<5)"]
+socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"]
+zstd = ["backports-zstd (>=1.0.0) ; python_version < \"3.14\""]
+
+[[package]]
+name = "zipp"
+version = "3.23.1"
+description = "Backport of pathlib-compatible object wrapper for zip files"
+optional = false
+python-versions = ">=3.9"
+groups = ["dev"]
+markers = "platform_machine != \"ppc64le\" and platform_machine != \"s390x\" and python_version == \"3.11\""
+files = [
+ {file = "zipp-3.23.1-py3-none-any.whl", hash = "sha256:0b3596c50a5c700c9cb40ba8d86d9f2cc4807e9bedb06bcdf7fac85633e444dc"},
+ {file = "zipp-3.23.1.tar.gz", hash = "sha256:32120e378d32cd9714ad503c1d024619063ec28aad2248dc6672ad13edfa5110"},
+]
+
+[package.extras]
+check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\""]
+cover = ["pytest-cov"]
+doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"]
+enabler = ["pytest-enabler (>=2.2)"]
+test = ["big-O", "jaraco.functools", "jaraco.itertools", "jaraco.test", "more_itertools", "pytest (>=6,!=8.1.*)", "pytest-ignore-flaky"]
+type = ["pytest-mypy"]
+
+[metadata]
+lock-version = "2.1"
+python-versions = ">=3.11"
+content-hash = "2d2a6d584a1cf9e4fb2a2ddc0b48c3260a58771c27b44e9df6f299ea37a062e1"

diff --git a/vendor/lxmfy/pyproject.toml b/vendor/lxmfy/pyproject.toml
new file mode 100644
index 00000000..6d084dd3
--- /dev/null
+++ b/vendor/lxmfy/pyproject.toml
@@ -0,0 +1,55 @@
+[project]
+name = "lxmfy"
+version = "1.6.2"
+description = "LXMF bot framework for creating bots for the Reticulum Network"
+authors = [{name = "Quad4", email = "team@quad4.io"}]
+readme = "README.md"
+license = "BSD-0-Clause"
+requires-python = ">=3.11"
+keywords = ["lxmf", "reticulum", "bot", "framework", "rns"]
+classifiers = [
+ "Programming Language :: Python :: 3",
+ "Programming Language :: Python :: 3.11",
+ "Programming Language :: Python :: 3.12",
+ "Programming Language :: Python :: 3.13",
+ "Operating System :: OS Independent",
+]
+dependencies = [
+ "lxmf>=0.9.6",
+ "rns>=1.2.0"
+]
+
+[project.urls]
+Homepage = "https://git.quad4.io/LXMFy/LXMFy"
+Repository = "https://git.quad4.io/LXMFy/LXMFy"
+
+[project.scripts]
+lxmfy = "lxmfy.cli:main"
+
+[tool.poetry]
+packages = [{include = "lxmfy"}]
+
+[tool.poetry.group.dev.dependencies]
+ruff = "^0.14.3"
+pytest = "^8.4.2"
+pytest-asyncio = "^1.2.0"
+pytest-cov = "^7.0.0"
+twine = "^6.2.0"
+psutil = "^7.2.1"
+hypothesis = "^6.150.2"
+
+[tool.pytest.ini_options]
+testpaths = ["tests"]
+python_files = ["test_*.py"]
+python_classes = ["Test*"]
+python_functions = ["test_*"]
+markers = [
+ "slow: marks tests as slow (deselect with '-m \"not slow\"')",
+ "integration: marks tests as integration tests",
+ "e2e: marks tests as end-to-end tests",
+ "reliability: marks tests for long-term stability and crash resistance",
+]
+
+[build-system]
+requires = ["poetry-core"]
+build-backend = "poetry.core.masonry.api"
\ No newline at end of file

diff --git a/vendor/lxmfy/scripts/osv_scan.sh b/vendor/lxmfy/scripts/osv_scan.sh
new file mode 100644
index 00000000..d62e1f07
--- /dev/null
+++ b/vendor/lxmfy/scripts/osv_scan.sh
@@ -0,0 +1,38 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+OSV_VERSION="${OSV_VERSION:-v2.3.1}"
+
+echo "Installing OSV-Scanner ${OSV_VERSION}..."
+curl -sSL "https://github.com/google/osv-scanner/releases/download/${OSV_VERSION}/osv-scanner_linux_amd64" -o /tmp/osv-scanner
+chmod +x /tmp/osv-scanner
+sudo mv /tmp/osv-scanner /usr/local/bin/osv-scanner
+
+echo "Running OSV-Scanner recursively..."
+OSV_JSON="$(mktemp)"
+trap 'rm -f "$OSV_JSON"' EXIT
+
+osv-scanner --recursive ./ --format json > "$OSV_JSON" || true
+
+if ! command -v jq >/dev/null 2>&1; then
+ echo "Error: jq is not installed. Please install jq to parse OSV results."
+ exit 1
+fi
+
+VULNS=$(jq -r '
+ .results[]? |
+ .source as $src |
+ .vulns[]? |
+ "\(.id) (source: \($src))"
+' "$OSV_JSON")
+
+if [ -n "$VULNS" ]; then
+ echo "OSV scan found vulnerabilities:"
+ echo "$VULNS" | while IFS= read -r line; do
+ echo " - $line"
+ done
+ exit 1
+else
+ echo "OSV scan: no vulnerabilities found."
+fi
+

diff --git a/vendor/lxmfy/tests/__init__.py b/vendor/lxmfy/tests/__init__.py
new file mode 100644
index 00000000..2ade9bcb
--- /dev/null
+++ b/vendor/lxmfy/tests/__init__.py
@@ -0,0 +1 @@
+# Test package for LXMFy

diff --git a/vendor/lxmfy/tests/conftest.py b/vendor/lxmfy/tests/conftest.py
new file mode 100644
index 00000000..068200fd
--- /dev/null
+++ b/vendor/lxmfy/tests/conftest.py
@@ -0,0 +1,179 @@
+"""Test configuration and fixtures for LXMFy tests."""
+
+import os
+import tempfile
+from pathlib import Path
+
+import pytest
+import RNS
+from LXMF import LXMRouter
+
+from lxmfy import BotConfig, LXMFBot
+
+
+@pytest.fixture(scope="session")
+def test_config_dir():
+ """Create a temporary directory for test configurations."""
+ with tempfile.TemporaryDirectory() as temp_dir:
+ config_path = Path(temp_dir) / "test_config"
+ config_path.mkdir(exist_ok=True)
+ yield config_path
+
+
+@pytest.fixture(scope="session")
+def reticulum_instance(test_config_dir):
+ """Initialize a Reticulum instance for testing."""
+ config_dir = test_config_dir / "reticulum"
+ config_dir.mkdir(exist_ok=True)
+
+ # Initialize Reticulum with test config
+ reticulum = RNS.Reticulum(
+ configdir=str(config_dir),
+ loglevel=RNS.LOG_CRITICAL, # Minimize logging in tests
+ verbosity=0,
+ )
+ yield reticulum
+
+ # Cleanup
+ try:
+ RNS.Reticulum.exit_handler()
+ except Exception:
+ pass
+
+
+@pytest.fixture(scope="function")
+def test_identity(reticulum_instance, test_config_dir):
+ """Create a test identity."""
+ identity = RNS.Identity()
+ return identity
+
+
+@pytest.fixture(scope="function")
+def test_destination(test_identity, test_config_dir):
+ """Create a test destination for messaging."""
+ dest = RNS.Destination(
+ test_identity,
+ RNS.Destination.IN,
+ RNS.Destination.SINGLE,
+ "lxmf",
+ "test",
+ )
+ dest.set_proof_strategy(RNS.Destination.PROVE_NONE) # Disable proof for tests
+ return dest
+
+
+@pytest.fixture(scope="function")
+def lxmf_router(test_identity, test_config_dir):
+ """Create an LXMF router for testing."""
+ storage_path = test_config_dir / "lxmf_storage"
+ storage_path.mkdir(exist_ok=True)
+
+ router = LXMRouter(
+ identity=test_identity,
+ storagepath=str(storage_path),
+ autopeer=False, # Disable auto-peering in tests
+ propagation_limit=10,
+ delivery_limit=10,
+ )
+
+ # Register delivery identity (creates destination internally)
+ delivery_destination = router.register_delivery_identity(
+ test_identity,
+ display_name="TestRouter",
+ )
+
+ # Store the delivery destination for tests
+ router._test_delivery_dest = delivery_destination
+
+ return router
+
+
+@pytest.fixture(scope="function")
+def test_bot_config(test_config_dir):
+ """Create a test bot configuration."""
+ return BotConfig(
+ name="TestBot",
+ announce=0, # Disable announcing in tests
+ announce_enabled=False,
+ admins=set(),
+ hot_reloading=False,
+ rate_limit=100, # High rate limit for tests
+ cooldown=1,
+ max_warnings=10,
+ warning_timeout=60,
+ command_prefix="/",
+ cogs_enabled=False,
+ permissions_enabled=False,
+ storage_type="json",
+ storage_path=str(test_config_dir / "bot_storage"),
+ first_message_enabled=False,
+ signature_verification_enabled=False,
+ require_message_signatures=False,
+ require_stamps=False,
+ stamp_cost=None,
+ test_mode=True, # Enable test mode to skip RNS initialization
+ )
+
+
+@pytest.fixture(scope="function")
+def test_bot(test_bot_config, test_config_dir):
+ """Create a test bot instance."""
+ # Override config_path for testing
+ # Use a unique config path per test
+ import uuid
+
+ unique_config_path = test_config_dir / f"bot_{uuid.uuid4().hex[:8]}"
+ unique_config_path.mkdir(exist_ok=True)
+
+ config = test_bot_config.__dict__.copy()
+ config["storage_path"] = str(unique_config_path / "storage")
+
+ bot = LXMFBot(**config)
+ bot.config_path = str(unique_config_path)
+
+ yield bot
+
+ # Cleanup
+ try:
+ bot.cleanup()
+ except Exception:
+ pass
+
+
+@pytest.fixture(scope="function")
+def test_message_data():
+ """Sample message data for testing."""
+ return {
+ "content": "Test message content",
+ "title": "Test Title",
+ "source_hash": "abc123def456",
+ "destination_hash": "def456abc789",
+ }
+
+
+@pytest.fixture(scope="function")
+def temp_file():
+ """Create a temporary file for testing."""
+ with tempfile.NamedTemporaryFile(mode="w", delete=False) as f:
+ f.write("Test file content")
+ temp_path = f.name
+
+ yield temp_path
+
+ # Cleanup
+ try:
+ os.unlink(temp_path)
+ except Exception:
+ pass
+
+
+@pytest.fixture(autouse=True)
+def cleanup_reticulum():
+ """Clean up Reticulum state between tests."""
+ yield
+ # Force cleanup of any lingering links or destinations
+ try:
+ # This is a best-effort cleanup
+ pass
+ except Exception:
+ pass

diff --git a/vendor/lxmfy/tests/pytest.ini b/vendor/lxmfy/tests/pytest.ini
new file mode 100644
index 00000000..6aab9cdd
--- /dev/null
+++ b/vendor/lxmfy/tests/pytest.ini
@@ -0,0 +1,17 @@
+[tool:pytest]
+testpaths = tests
+python_files = test_*.py
+python_classes = Test*
+python_functions = test_*
+addopts =
+ --verbose
+ --tb=short
+ --cov=lxmfy
+ --cov-report=term-missing
+ --cov-report=html:htmlcov
+ --cov-fail-under=80
+markers =
+ slow: marks tests as slow (deselect with '-m "not slow"')
+ integration: marks tests as integration tests
+ e2e: marks tests as end-to-end tests
+ reliability: marks tests for long-term stability and crash resistance

diff --git a/vendor/lxmfy/tests/test_chaos.py b/vendor/lxmfy/tests/test_chaos.py
new file mode 100644
index 00000000..a05ca794
--- /dev/null
+++ b/vendor/lxmfy/tests/test_chaos.py
@@ -0,0 +1,136 @@
+"""Chaos and fault injection testing for off-grid reliability."""
+
+import random
+import time
+import pytest
+from unittest.mock import MagicMock, patch
+from lxmfy import BotConfig, LXMFBot
+from lxmfy.storage import Storage, JSONStorage
+
+
+class FailingStorageBackend:
+ """A storage backend that periodically fails or corrupts data."""
+
+ def __init__(self, real_backend, failure_rate=0.1):
+ self.real = real_backend
+ self.failure_rate = failure_rate
+
+ def set(self, key, value):
+ if random.random() < self.failure_rate:
+ # Simulate a "Partial Write" by corrupting the value
+ if isinstance(value, str):
+ value = value[: len(value) // 2] + " [CORRUPTED] "
+ elif isinstance(value, dict):
+ value["corrupt"] = True
+ return self.real.set(key, value)
+
+ def get(self, key, default=None):
+ if random.random() < self.failure_rate:
+ raise OSError("I/O Error: SD Card Read Failed (Simulated)")
+ return self.real.get(key, default)
+
+ def delete(self, key):
+ return self.real.delete(key)
+
+ def exists(self, key):
+ return self.real.exists(key)
+
+ def scan(self, prefix):
+ return self.real.scan(prefix)
+
+
+@pytest.mark.reliability
+class TestChaosBot:
+ """Stress testing the bot under simulated hardware/protocol failures."""
+
+ def test_time_drift_resilience(self, test_config_dir):
+ """Verify that the bot handles large system clock jumps."""
+ config = BotConfig(
+ name="TimeDriftBot",
+ storage_path=str(test_config_dir / "time_drift"),
+ test_mode=True,
+ )
+ bot = LXMFBot(**config.__dict__)
+
+ # Mocking time.time
+ start_time = 1700000000.0
+
+ with patch("time.time", return_value=start_time):
+ # Record some activity
+ bot.storage.set("last_seen", time.time())
+
+ # Jump forward 1 year
+ future_time = start_time + (365 * 24 * 3600)
+ with patch("time.time", return_value=future_time):
+ # Bot should still function
+ bot.storage.set("current_event", "Checking after drift")
+ last_seen = bot.storage.get("last_seen")
+ assert last_seen == start_time
+
+ # Jump backward 1 year (RTC battery failure)
+ past_time = start_time - (365 * 24 * 3600)
+ with patch("time.time", return_value=past_time):
+ bot.storage.set("panic_event", "Clock rolled back")
+ assert bot.storage.get("panic_event") == "Clock rolled back"
+
+ def test_storage_fault_injection(self, test_config_dir):
+ """Verify the bot can survive intermittent storage failures."""
+ json_backend = JSONStorage(str(test_config_dir / "chaos_json"))
+ chaos_backend = FailingStorageBackend(json_backend, failure_rate=0.2)
+ storage = Storage(chaos_backend)
+
+ success_count = 0
+ error_count = 0
+
+ for i in range(100):
+ try:
+ storage.set(f"key_{i}", {"data": "important info"})
+ val = storage.get(f"key_{i}")
+ if val:
+ success_count += 1
+ except Exception:
+ error_count += 1
+
+ print(
+ f"\n[Chaos Storage] Successes: {success_count}, Simulated Errors: {error_count}"
+ )
+ # The goal is not 100% success, but that the framework doesn't CRASH the entire process
+ assert (success_count + error_count) == 100
+
+ def test_message_storm_deduplication(self, test_config_dir):
+ """Test the bot's ability to handle massive duplicate message storms."""
+ config = BotConfig(
+ name="StormBot",
+ storage_path=str(test_config_dir / "storm_storage"),
+ test_mode=True,
+ first_message_enabled=False,
+ )
+ bot = LXMFBot(**config.__dict__)
+
+ processed_count = 0
+
+ def handler(sender, msg):
+ nonlocal processed_count
+ processed_count += 1
+ return True
+
+ bot.message_handlers.append(handler)
+
+ # Create a mock message
+ import LXMF
+
+ mock_msg = MagicMock(spec=LXMF.LXMessage)
+ mock_msg.content = b"Single message"
+ mock_msg.hash = b"unique_hash_123"
+ mock_msg.source_hash = b"sender"
+ mock_msg.destination_hash = b"local"
+ mock_msg.fields = {}
+ mock_msg.signature_validated = True
+
+ # Send the EXACT SAME message 50 times
+ with patch("lxmfy.core.verify_incoming_message", return_value=True):
+ for _ in range(50):
+ bot._message_received(mock_msg)
+
+ # Should only have been processed ONCE due to deduplication receipts
+ assert processed_count == 1

diff --git a/vendor/lxmfy/tests/test_cli.py b/vendor/lxmfy/tests/test_cli.py
new file mode 100644
index 00000000..68af4115
--- /dev/null
+++ b/vendor/lxmfy/tests/test_cli.py
@@ -0,0 +1,441 @@
+"""Tests for LXMFy CLI functionality."""
+
+import os
+from unittest.mock import MagicMock, patch
+
+import pytest
+
+from lxmfy.cli import (
+ create_bot_file,
+ create_example_cog,
+ create_from_template,
+ get_bot_name,
+ get_template_choice,
+ get_user_choice,
+ interactive_create,
+ interactive_run,
+ is_safe_path,
+ main,
+ sanitize_filename,
+ validate_bot_name,
+)
+from lxmfy.colors import (
+ Colors,
+ print_error,
+ print_header,
+ print_info,
+ print_menu,
+ print_success,
+ print_warning,
+)
+
+
+class TestColors:
+ """Test Colors class."""
+
+ def test_colors_defined(self):
+ """Test that all color constants are defined."""
+ assert Colors.HEADER == "\033[95m"
+ assert Colors.BLUE == "\033[94m"
+ assert Colors.CYAN == "\033[96m"
+ assert Colors.GREEN == "\033[92m"
+ assert Colors.YELLOW == "\033[93m"
+ assert Colors.RED == "\033[91m"
+ assert Colors.ENDC == "\033[0m"
+ assert Colors.BOLD == "\033[1m"
+ assert Colors.UNDERLINE == "\033[4m"
+
+
+class TestPrintFunctions:
+ """Test print utility functions."""
+
+ @patch("lxmfy.colors.Colors.is_colors_supported", return_value=True)
+ @patch("builtins.print")
+ def test_print_header(self, mock_print, mock_colors):
+ """Test print_header function."""
+ print_header("Test Header")
+ mock_print.assert_called()
+
+ @patch("lxmfy.colors.Colors.is_colors_supported", return_value=True)
+ @patch("builtins.print")
+ def test_print_success(self, mock_print, mock_colors):
+ """Test print_success function."""
+ print_success("Test message")
+ mock_print.assert_called_with(
+ f"{Colors.GREEN}{Colors.BOLD}✓ Test message{Colors.ENDC}",
+ )
+
+ @patch("lxmfy.colors.Colors.is_colors_supported", return_value=True)
+ @patch("builtins.print")
+ def test_print_error(self, mock_print, mock_colors):
+ """Test print_error function."""
+ print_error("Test error")
+ mock_print.assert_called_with(
+ f"{Colors.RED}{Colors.BOLD}✗ Test error{Colors.ENDC}",
+ )
+
+ @patch("lxmfy.colors.Colors.is_colors_supported", return_value=True)
+ @patch("builtins.print")
+ def test_print_info(self, mock_print, mock_colors):
+ """Test print_info function."""
+ print_info("Test info")
+ mock_print.assert_called_with(
+ f"{Colors.BLUE}{Colors.BOLD}ℹ Test info{Colors.ENDC}",
+ )
+
+ @patch("lxmfy.colors.Colors.is_colors_supported", return_value=True)
+ @patch("builtins.print")
+ def test_print_warning(self, mock_print, mock_colors):
+ """Test print_warning function."""
+ print_warning("Test warning")
+ mock_print.assert_called_with(
+ f"{Colors.YELLOW}{Colors.BOLD}⚠ Test warning{Colors.ENDC}",
+ )
+
+ @patch("lxmfy.colors.Colors.is_colors_supported", return_value=True)
+ @patch("builtins.print")
+ def test_print_menu(self, mock_print, mock_colors):
+ """Test print_menu function."""
+ print_menu()
+ assert mock_print.call_count > 5
+
+
+class TestInputFunctions:
+ """Test input handling functions."""
+
+ @patch("builtins.input")
+ def test_get_user_choice_valid(self, mock_input):
+ """Test get_user_choice with valid input."""
+ mock_input.return_value = "1"
+ result = get_user_choice()
+ assert result == "1"
+
+ @patch("builtins.input")
+ @patch("lxmfy.cli.print_error")
+ def test_get_user_choice_invalid_then_valid(self, mock_print_error, mock_input):
+ """Test get_user_choice with invalid then valid input."""
+ mock_input.side_effect = ["4", "2"]
+ result = get_user_choice()
+ assert result == "2"
+ mock_print_error.assert_called_once()
+
+ @patch("builtins.input")
+ @patch("lxmfy.cli.validate_bot_name")
+ def test_get_bot_name_valid(self, mock_validate, mock_input):
+ """Test get_bot_name with valid input."""
+ mock_input.return_value = "testbot"
+ mock_validate.return_value = "testbot"
+ result = get_bot_name()
+ assert result == "testbot"
+
+ @patch("builtins.input")
+ @patch("lxmfy.cli.print_error")
+ @patch("lxmfy.cli.validate_bot_name")
+ def test_get_bot_name_invalid_then_valid(
+ self,
+ mock_validate,
+ mock_print_error,
+ mock_input,
+ ):
+ """Test get_bot_name with invalid then valid input."""
+ mock_validate.side_effect = [ValueError("Invalid"), "validbot"]
+ mock_input.side_effect = ["invalid", "validbot"]
+ result = get_bot_name()
+ assert result == "validbot"
+ mock_print_error.assert_called_once()
+
+ @patch("builtins.input")
+ def test_get_template_choice_valid(self, mock_input):
+ """Test get_template_choice with valid input."""
+ mock_input.side_effect = ["1"] # Choose basic template
+ result = get_template_choice()
+ assert result == "basic"
+
+ @patch("builtins.input")
+ @patch("lxmfy.cli.print_error")
+ def test_get_template_choice_invalid_then_valid(self, mock_print_error, mock_input):
+ """Test get_template_choice with invalid then valid input."""
+ mock_input.side_effect = ["6", "3"] # Invalid then reminder
+ result = get_template_choice()
+ assert result == "reminder"
+ mock_print_error.assert_called_once()
+
+
+class TestUtilityFunctions:
+ """Test utility functions."""
+
+ def test_sanitize_filename_basic(self):
+ """Test sanitize_filename with basic filename."""
+ result = sanitize_filename("test file!.txt")
+ assert result == "testfile.py" # Removes special chars and forces .py extension
+
+ def test_sanitize_filename_no_extension(self):
+ """Test sanitize_filename with no extension."""
+ result = sanitize_filename("test file!")
+ assert result == "testfile.py"
+
+ def test_sanitize_filename_extension_override(self):
+ """Test sanitize_filename forces .py extension."""
+ result = sanitize_filename("test.js")
+ assert result == "test.py" # Forces .py extension
+
+ def test_validate_bot_name_valid(self):
+ """Test validate_bot_name with valid name."""
+ result = validate_bot_name("TestBot123")
+ assert result == "TestBot123"
+
+ def test_validate_bot_name_with_spaces(self):
+ """Test validate_bot_name with spaces and special chars."""
+ result = validate_bot_name("Test Bot!")
+ assert result == "Test Bot" # Removes special chars
+
+ def test_validate_bot_name_empty(self):
+ """Test validate_bot_name with empty string."""
+ with pytest.raises(ValueError, match="Bot name cannot be empty"):
+ validate_bot_name("")
+
+ def test_validate_bot_name_only_special(self):
+ """Test validate_bot_name with only special characters."""
+ with pytest.raises(ValueError, match="Bot name must contain valid characters"):
+ validate_bot_name("!@#$%")
+
+ def test_is_safe_path_no_base(self):
+ """Test is_safe_path without base path."""
+ assert is_safe_path("/some/path") is True
+
+ def test_is_safe_path_safe(self):
+ """Test is_safe_path with safe path."""
+ assert is_safe_path("/base/safe/path", "/base") is True
+
+ def test_is_safe_path_unsafe(self):
+ """Test is_safe_path with unsafe path."""
+ assert is_safe_path("/unsafe/path", "/base") is False
+
+ def test_is_safe_path_invalid(self):
+ """Test is_safe_path with invalid path."""
+ assert is_safe_path("", "/base") is False
+
+
+class TestFileCreation:
+ """Test file creation functions."""
+
+ def test_create_bot_file_basic(self, tmp_path):
+ """Test create_bot_file creates a basic bot file."""
+ output_path = tmp_path / "test_bot.py"
+ result = create_bot_file("TestBot", str(output_path))
+
+ assert os.path.exists(output_path)
+ assert result.endswith("test_bot.py")
+
+ with open(output_path) as f:
+ content = f.read()
+ assert "from lxmfy import LXMFBot" in content
+ assert 'name="TestBot"' in content
+
+ def test_create_bot_file_no_cogs(self, tmp_path):
+ """Test create_bot_file with no_cogs=True."""
+ output_path = tmp_path / "test_bot.py"
+ result = create_bot_file("TestBot", str(output_path), no_cogs=True)
+
+ assert result.endswith("test_bot.py")
+
+ with open(output_path) as f:
+ content = f.read()
+ assert "cogs_enabled=False" in content
+
+ def test_create_example_cog(self, tmp_path):
+ """Test create_example_cog creates cog files."""
+ bot_path = tmp_path / "test_bot.py"
+ bot_path.write_text("# Test bot file")
+
+ create_example_cog(str(bot_path))
+
+ cogs_dir = tmp_path / "cogs"
+ assert cogs_dir.exists()
+
+ init_file = cogs_dir / "__init__.py"
+ assert init_file.exists()
+
+ basic_cog = cogs_dir / "basic.py"
+ assert basic_cog.exists()
+
+ with open(basic_cog) as f:
+ content = f.read()
+ assert "from lxmfy import Command" in content
+ assert "class BasicCommands:" in content
+
+ def test_create_from_template_basic(self, tmp_path):
+ """Test create_from_template with basic template."""
+ output_path = tmp_path / "test_bot.py"
+ result = create_from_template("basic", str(output_path), "TestBot")
+
+ assert os.path.exists(output_path)
+ assert result.endswith("test_bot.py")
+
+ def test_create_from_template_echo(self, tmp_path):
+ """Test create_from_template with echo template."""
+ output_path = tmp_path / "echo_bot.py"
+ result = create_from_template("echo", str(output_path), "EchoBot")
+
+ assert os.path.exists(output_path)
+ assert result.endswith("echo_bot.py")
+
+ with open(output_path) as f:
+ content = f.read()
+ assert "from lxmfy.templates import EchoBot" in content
+
+ def test_create_from_template_invalid(self, tmp_path):
+ """Test create_from_template with invalid template."""
+ output_path = tmp_path / "invalid_bot.py"
+ with pytest.raises(RuntimeError, match="Invalid template"):
+ create_from_template("invalid", str(output_path), "InvalidBot")
+
+
+class TestInteractiveFunctions:
+ """Test interactive functions."""
+
+ @patch("builtins.input")
+ @patch("lxmfy.cli.create_from_template")
+ @patch("lxmfy.cli.print_success")
+ @patch("lxmfy.cli.print_info")
+ def test_interactive_create_basic(
+ self,
+ mock_print_info,
+ mock_print_success,
+ mock_create,
+ mock_input,
+ ):
+ """Test interactive_create with basic template."""
+ mock_input.side_effect = ["TestBot", "1", "test_bot.py"]
+ mock_create.return_value = "test_bot.py"
+
+ interactive_create()
+
+ mock_create.assert_called_once_with("basic", "test_bot.py", "TestBot")
+ mock_print_success.assert_called()
+ mock_print_info.assert_called()
+
+ @patch("builtins.input")
+ @patch("lxmfy.cli.create_from_template")
+ @patch("lxmfy.cli.print_success")
+ @patch("lxmfy.cli.print_info")
+ def test_interactive_create_with_cog(
+ self,
+ mock_print_info,
+ mock_print_success,
+ mock_create,
+ mock_input,
+ ):
+ """Test interactive_create creates example cog."""
+ mock_input.side_effect = ["TestBot", "1", ""] # Empty output path
+ mock_create.return_value = "TestBot.py"
+
+ interactive_create()
+
+ mock_create.assert_called_once_with("basic", "TestBot.py", "TestBot")
+
+ @patch("builtins.input")
+ @patch("builtins.print")
+ @patch("lxmfy.cli.print_header")
+ @patch("lxmfy.cli.get_template_choice")
+ def test_interactive_run(
+ self,
+ mock_get_template,
+ mock_print_header,
+ mock_print,
+ mock_input,
+ ):
+ """Test interactive_run function."""
+ mock_input.side_effect = ["CustomName"]
+ mock_get_template.return_value = "echo"
+
+ # Mock the EchoBot template
+ with patch("lxmfy.cli.EchoBot") as mock_echo_bot:
+ mock_bot_instance = MagicMock()
+ mock_echo_bot.return_value = mock_bot_instance
+
+ interactive_run()
+
+ mock_echo_bot.assert_called_once()
+ mock_bot_instance.run.assert_called_once()
+
+
+class TestMainFunction:
+ """Test main function."""
+
+ @patch("sys.argv", ["lxmfy"])
+ @patch("lxmfy.cli.interactive_mode")
+ def test_main_interactive_mode(self, mock_interactive):
+ """Test main function calls interactive_mode when no args."""
+ main()
+ mock_interactive.assert_called_once()
+
+ @patch("sys.argv", ["lxmfy", "create", "testbot"])
+ @patch("lxmfy.cli.create_from_template")
+ @patch("lxmfy.cli.print_success")
+ @patch("lxmfy.cli.print_info")
+ def test_main_create_command(
+ self,
+ mock_print_info,
+ mock_print_success,
+ mock_create,
+ ):
+ """Test main function create command."""
+ mock_create.return_value = "testbot.py"
+
+ main()
+
+ mock_create.assert_called_once_with("basic", "testbot.py", "testbot")
+ mock_print_success.assert_called()
+ mock_print_info.assert_called()
+
+ @patch("sys.argv", ["lxmfy", "run", "echo"])
+ @patch("lxmfy.cli.EchoBot")
+ def test_main_run_command(self, mock_echo_bot):
+ """Test main function run command."""
+ mock_bot_instance = MagicMock()
+ mock_echo_bot.return_value = mock_bot_instance
+
+ main()
+
+ mock_echo_bot.assert_called_once()
+ mock_bot_instance.run.assert_called_once()
+
+ @patch("sys.argv", ["lxmfy", "signatures", "test"])
+ @patch("builtins.print")
+ def test_main_signatures_test(self, mock_print):
+ """Test main function signatures test command."""
+ main()
+ # Should print signature test messages
+ assert mock_print.call_count > 5
+
+ @patch("sys.argv", ["lxmfy", "signatures", "enable"])
+ @patch("builtins.print")
+ def test_main_signatures_enable(self, mock_print):
+ """Test main function signatures enable command."""
+ main()
+ mock_print.assert_called()
+
+ @patch("sys.argv", ["lxmfy", "signatures", "disable"])
+ @patch("builtins.print")
+ def test_main_signatures_disable(self, mock_print):
+ """Test main function signatures disable command."""
+ main()
+ mock_print.assert_called()
+
+ @patch("sys.argv", ["lxmfy", "signatures", "invalid"])
+ @patch("lxmfy.cli.print_error")
+ @patch("lxmfy.cli.print_info")
+ def test_main_signatures_invalid(self, mock_print_info, mock_print_error):
+ """Test main function signatures invalid command."""
+ # This should not crash and should print error messages
+ try:
+ main()
+ except SystemExit:
+ pass # Expected when invalid subcommand is provided
+
+ # Should print error about unknown subcommand
+ mock_print_error.assert_called_with("Unknown subcommand: invalid")
+ mock_print_info.assert_called_with(
+ "Available subcommands: test, enable, disable",
+ )

diff --git a/vendor/lxmfy/tests/test_client.py b/vendor/lxmfy/tests/test_client.py
new file mode 100644
index 00000000..0b68a55e
--- /dev/null
+++ b/vendor/lxmfy/tests/test_client.py
@@ -0,0 +1,379 @@
+"""Tests for LXMFy client functionality and RNS/LXMF integration."""
+
+from unittest.mock import Mock
+
+import RNS
+from LXMF import LXMessage
+
+from lxmfy import BotConfig, LXMFBot
+
+
+class TestRNSBasicFunctionality:
+ """Test basic RNS functionality required for LXMFy."""
+
+ def test_identity_creation(self, reticulum_instance):
+ """Test RNS identity creation."""
+ identity = RNS.Identity()
+ assert identity is not None
+ assert identity.hash is not None
+ assert len(identity.hash) == RNS.Reticulum.TRUNCATED_HASHLENGTH // 8
+
+ def test_destination_creation(self, test_identity, reticulum_instance):
+ """Test RNS destination creation."""
+ dest = RNS.Destination(
+ test_identity,
+ RNS.Destination.IN,
+ RNS.Destination.SINGLE,
+ "test",
+ "app",
+ )
+
+ assert dest is not None
+ assert dest.hash is not None
+ # Check direction (IN/OUT) and type (SINGLE/GROUP/etc.)
+ assert dest.direction == RNS.Destination.IN
+ assert dest.type == RNS.Destination.SINGLE
+
+ def test_identity_recalling(self, test_identity):
+ """Test identity recall functionality."""
+ # Store identity hash
+ identity_hash = test_identity.hash
+
+ # Recall identity by hash - this should work for identities that have been seen
+ # In a real network, this would recall from the identity cache
+ RNS.Identity.recall(identity_hash)
+
+ # Note: In test environment, recall might return None if identity hasn't been
+ # registered in the network. Let's test the hash consistency instead
+ assert identity_hash is not None
+ assert len(identity_hash) == RNS.Reticulum.TRUNCATED_HASHLENGTH // 8
+
+ # Test that we can create a destination and recall its identity
+ dest = RNS.Destination(
+ test_identity,
+ RNS.Destination.IN,
+ RNS.Destination.SINGLE,
+ "test",
+ "recall",
+ )
+
+ # The destination's identity should be recallable
+ RNS.Identity.recall(dest.hash)
+ # This might be None in test environment, but the hash should be valid
+ assert dest.hash is not None
+
+ def test_path_request_simulation(self, test_destination):
+ """Test path request functionality (simulated)."""
+ destination_hash = test_destination.hash
+
+ # Request path (this would normally contact the network)
+ RNS.Transport.request_path(destination_hash)
+
+ # In a real network, we'd wait for path establishment
+ # For testing, we just verify the call doesn't crash
+ assert destination_hash is not None
+
+
+class TestLXMFMessageHandling:
+ """Test LXMF message creation and handling."""
+
+ def test_lxmf_message_creation(self, lxmf_router, test_identity):
+ """Test creating LXMF messages."""
+ # Create a destination for the message
+ dest = RNS.Destination(
+ test_identity,
+ RNS.Destination.OUT,
+ RNS.Destination.SINGLE,
+ "lxmf",
+ "delivery",
+ )
+
+ message = LXMessage(
+ destination=dest,
+ source=lxmf_router._test_delivery_dest,
+ content=b"Test message content",
+ title=b"Test Title",
+ )
+
+ assert message.content == b"Test message content"
+ assert message.title == b"Test Title"
+ assert message.source_hash == lxmf_router._test_delivery_dest.hash
+ assert message.destination_hash == dest.hash
+
+ def test_lxmf_message_fields(self, lxmf_router, test_identity):
+ """Test LXMF message with custom fields."""
+ from lxmfy.signatures import FIELD_SIGNATURE
+
+ # Create destination
+ dest = RNS.Destination(
+ test_identity,
+ RNS.Destination.OUT,
+ RNS.Destination.SINGLE,
+ "lxmf",
+ "delivery",
+ )
+
+ message = LXMessage(
+ destination=dest,
+ source=lxmf_router._test_delivery_dest,
+ content=b"Test message",
+ fields={
+ "custom_field": "custom_value",
+ FIELD_SIGNATURE: b"signature_data",
+ },
+ )
+
+ assert message.fields is not None
+ assert message.fields["custom_field"] == "custom_value"
+ assert message.fields[FIELD_SIGNATURE] == b"signature_data"
+
+ def test_lxmf_router_operations(self, lxmf_router, test_identity):
+ """Test LXMF router operations."""
+ # Test delivery identity
+ delivery_id = lxmf_router._test_delivery_dest
+ assert delivery_id is not None
+
+ # Test message handling
+ dest = RNS.Destination(
+ test_identity,
+ RNS.Destination.OUT,
+ RNS.Destination.SINGLE,
+ "lxmf",
+ "delivery",
+ )
+
+ message = LXMessage(
+ destination=dest,
+ source=lxmf_router._test_delivery_dest,
+ content=b"Router test message",
+ )
+
+ # Should handle outbound without errors
+ lxmf_router.handle_outbound(message)
+
+
+class TestClientBotInteraction:
+ """Test client-side interaction with bots."""
+
+ def test_client_message_creation(self, test_bot):
+ """Test creating client messages to send to bots."""
+ # Mock the send method to capture what would be sent
+ sent_messages = []
+
+ def mock_send(
+ destination,
+ message,
+ title=None,
+ lxmf_fields=None,
+ stamp_cost=None,
+ ):
+ sent_messages.append(
+ {
+ "destination": destination,
+ "message": message,
+ "title": title,
+ "fields": lxmf_fields,
+ "stamp_cost": stamp_cost,
+ },
+ )
+
+ original_send = test_bot.send
+ test_bot.send = mock_send
+
+ # Send a test message
+ test_bot.send(
+ "test_dest_hash",
+ "Hello Bot!",
+ title="Test Message",
+ lxmf_fields={"custom": "field"},
+ )
+
+ assert len(sent_messages) == 1
+ msg = sent_messages[0]
+ assert msg["destination"] == "test_dest_hash"
+ assert msg["message"] == "Hello Bot!"
+ assert msg["title"] == "Test Message"
+ assert msg["fields"] == {"custom": "field"}
+
+ test_bot.send = original_send
+
+ def test_client_command_simulation(self, test_bot):
+ """Test simulating client sending commands to bot."""
+ # Register a command
+ command_responses = []
+
+ @test_bot.command("greet")
+ def greet_cmd(ctx):
+ command_responses.append(f"Hello {ctx.sender}!")
+ ctx.reply(f"Hello {ctx.sender}!")
+
+ # Mock message reception
+ mock_message = Mock()
+ mock_message.content = b"/greet"
+ mock_message.hash = b"message_hash_123" # Mock hash attribute
+
+ sent_replies = []
+
+ def mock_send(dest, msg, title=None, **kwargs):
+ sent_replies.append((dest, msg, title))
+
+ original_send = test_bot.send
+ test_bot.send = mock_send
+
+ # Process the command message
+ test_bot._process_message(mock_message, "client_hash_123")
+
+ # Verify command was executed
+ assert len(command_responses) == 1
+ assert "client_hash_123" in command_responses[0]
+
+ # Verify reply was sent
+ assert len(sent_replies) == 1
+ dest, reply_msg, title = sent_replies[0]
+ assert dest == "client_hash_123"
+ assert reply_msg == "Hello client_hash_123!"
+
+ test_bot.send = original_send
+
+ def test_client_attachment_handling(self, test_bot):
+ """Test client sending messages with attachments."""
+ from lxmfy.attachments import Attachment, AttachmentType
+
+ # Create a test attachment
+ attachment = Attachment(
+ type=AttachmentType.FILE,
+ name="test.txt",
+ data=b"Test file content",
+ format="txt",
+ )
+
+ # Mock send to capture attachment data
+ sent_attachments = []
+
+ def mock_send(dest, msg, title=None, lxmf_fields=None, **kwargs):
+ sent_attachments.append(
+ {
+ "destination": dest,
+ "message": msg,
+ "title": title,
+ "fields": lxmf_fields,
+ "stamp_cost": kwargs.get("stamp_cost"),
+ },
+ )
+
+ original_send = test_bot.send
+ test_bot.send = mock_send
+
+ # Send message with attachment
+ test_bot.send_with_attachment(
+ "test_dest",
+ "Check out this file!",
+ attachment,
+ title="File Attachment",
+ )
+
+ assert len(sent_attachments) == 1
+ attachment_msg = sent_attachments[0]
+ assert attachment_msg["message"] == "Check out this file!"
+ assert attachment_msg["fields"] is not None
+ # The attachment should be packed into LXMF fields with field ID 5 (FILE_ATTACHMENTS)
+ assert 5 in attachment_msg["fields"]
+ assert attachment_msg["fields"][5] == [["test.txt", b"Test file content"]]
+
+ test_bot.send = original_send
+
+
+# Signature tests are covered in test_core.py
+
+
+class TestNetworkPathOperations:
+ """Test network path discovery and management."""
+
+ def test_path_discovery_simulation(self, test_destination):
+ """Test path discovery workflow."""
+ dest_hash = test_destination.hash
+
+ # Request path to destination
+ RNS.Transport.request_path(dest_hash)
+
+ # In testing environment, path won't be established
+ # but the call should not raise exceptions
+ assert dest_hash is not None
+
+ def test_bot_path_management(self, test_bot):
+ """Test bot's path management functionality."""
+ # Test that transport layer exists
+ assert hasattr(test_bot, "transport")
+
+ # Test path loading/saving (should not crash)
+ test_bot.transport.load_paths()
+ test_bot.transport.save_paths()
+
+
+class TestReticulumIntegration:
+ """Test deep Reticulum network integration."""
+
+ def test_reticulum_identity_persistence(self, test_config_dir):
+ """Test identity persistence across bot restarts."""
+ from unittest import mock
+
+ config_dir = test_config_dir / "identity_test"
+
+ # Mock LXMRouter to avoid RNS conflicts
+ with (
+ mock.patch("lxmfy.core.LXMRouter"),
+ mock.patch("lxmfy.core.RNS.Reticulum"),
+ mock.patch("lxmfy.core.RNS.Transport.register_destination"),
+ ):
+ # Create first bot instance
+ config1 = BotConfig(
+ storage_path=str(config_dir / "storage1"),
+ test_mode=True,
+ config_path=str(config_dir),
+ )
+ bot1 = LXMFBot(**config1.__dict__)
+
+ identity_hash = RNS.hexrep(bot1.identity.hash, delimit=False)
+
+ # Create second bot instance (should recall same identity)
+ config2 = BotConfig(
+ storage_path=str(config_dir / "storage2"),
+ test_mode=True,
+ config_path=str(config_dir),
+ )
+ bot2 = LXMFBot(**config2.__dict__)
+
+ identity_hash2 = RNS.hexrep(bot2.identity.hash, delimit=False)
+
+ # Should be the same identity (persisted)
+ assert identity_hash == identity_hash2
+
+ def test_link_establishment_simulation(self, test_destination):
+ """Test link establishment process (simulated)."""
+ # Create a link
+ link = RNS.Link(test_destination)
+
+ # In testing, link won't actually establish
+ # but object should be created without errors
+ assert link is not None
+ assert hasattr(link, "status")
+
+ # Clean up
+ link.teardown()
+
+ def test_bot_network_operations(self, test_bot):
+ """Test bot's network operations."""
+ # Test that send method exists and can be called
+ # (without mocking complex network operations)
+ dest_hash = "test_destination_hash"
+
+ # This should not raise an exception, even if path discovery fails
+ try:
+ test_bot.send(dest_hash, "Test message")
+ except Exception:
+ pass
+
+ # In test environment, this might fail due to network setup
+ # but the method should exist and be callable
+ assert hasattr(test_bot, "send")
+ assert callable(test_bot.send)

diff --git a/vendor/lxmfy/tests/test_core.py b/vendor/lxmfy/tests/test_core.py
new file mode 100644
index 00000000..2be6fb18
--- /dev/null
+++ b/vendor/lxmfy/tests/test_core.py
@@ -0,0 +1,354 @@
+"""Tests for LXMFy core functionality."""
+
+from pathlib import Path
+
+import RNS
+
+from lxmfy import BOT_DISPLAY_NAME_FILE, BotConfig, LXMFBot
+from lxmfy.commands import Command
+
+
+class TestBotConfig:
+ """Test BotConfig class."""
+
+ def test_default_config(self):
+ """Test default configuration values."""
+ config = BotConfig()
+ assert config.name == "LXMFBot"
+ assert config.announce == 600
+ assert config.announce_enabled is True
+ assert config.signature_verification_enabled is False
+ assert config.require_message_signatures is False
+
+ def test_custom_config(self):
+ """Test custom configuration values."""
+ config = BotConfig(
+ name="TestBot",
+ announce=300,
+ signature_verification_enabled=True,
+ require_message_signatures=True,
+ require_stamps=True,
+ request_unknown_identities=True,
+ stamp_cost=16,
+ )
+ assert config.name == "TestBot"
+ assert config.announce == 300
+ assert config.signature_verification_enabled is True
+ assert config.require_message_signatures is True
+ assert config.require_stamps is True
+ assert config.request_unknown_identities is True
+ assert config.stamp_cost == 16
+
+
+class TestLXMFBot:
+ """Test LXMFBot basic functionality."""
+
+ def test_bot_initialization(self, test_bot):
+ """Test bot initializes correctly."""
+ assert test_bot.config.name == "TestBot"
+ assert test_bot.commands is not None
+ assert test_bot.cogs is not None
+ assert test_bot.events is not None
+ assert test_bot.permissions is not None
+
+ def test_command_registration(self, test_bot):
+ """Test command registration works."""
+
+ @test_bot.command(name="test")
+ def test_command(ctx):
+ ctx.reply("Test response")
+
+ assert "test" in test_bot.commands
+ cmd = test_bot.commands["test"]
+ assert cmd.name == "test"
+ assert cmd.callback == test_command
+
+ def test_admin_check(self, test_bot):
+ """Test admin checking functionality."""
+ test_sender = "test_hash_123"
+
+ # Initially no admins
+ assert not test_bot.is_admin(test_sender)
+
+ # Add admin
+ test_bot.admins.add(test_sender)
+ assert test_bot.is_admin(test_sender)
+
+ # Remove admin
+ test_bot.admins.remove(test_sender)
+ assert not test_bot.is_admin(test_sender)
+
+ def test_bot_validation(self, test_bot):
+ """Test bot validation functionality."""
+ results = test_bot.validate()
+ # Should return a string with validation results
+ assert isinstance(results, str)
+ assert len(results) > 0
+
+ def test_name_property_aliases_config(self, test_bot):
+ assert test_bot.name == test_bot.config.name
+ test_bot.name = "RenamedBot"
+ assert test_bot.config.name == "RenamedBot"
+ assert test_bot.name == "RenamedBot"
+
+ def test_effective_announce_display_name_file_priority(
+ self,
+ test_bot_config,
+ test_config_dir,
+ ):
+ import uuid
+
+ unique = test_config_dir / f"bot_name_{uuid.uuid4().hex[:8]}"
+ unique.mkdir(exist_ok=True)
+ config = test_bot_config.__dict__.copy()
+ config["storage_path"] = str(unique / "storage")
+ config["announce_display_name_file"] = "custom_title.txt"
+ bot = LXMFBot(**config)
+ bot.config_path = str(unique)
+
+ (Path(bot.config_path) / BOT_DISPLAY_NAME_FILE).write_text(
+ "FromFile\n",
+ encoding="utf-8",
+ )
+ (Path(bot.config_path) / "custom_title.txt").write_text(
+ "FromCustom\n",
+ encoding="utf-8",
+ )
+ assert bot._effective_announce_display_name() == "FromCustom"
+
+ bot.config.announce_display_name_file = None
+ assert bot._effective_announce_display_name() == "FromFile"
+
+ (Path(bot.config_path) / BOT_DISPLAY_NAME_FILE).unlink()
+ assert bot._effective_announce_display_name() == bot.config.name
+
+ bot.cleanup()
+
+
+class TestCommandSystem:
+ """Test command system functionality."""
+
+ def test_command_creation(self):
+ """Test Command class creation."""
+ cmd = Command(
+ name="test_cmd",
+ description="A test command",
+ admin_only=True,
+ )
+
+ assert cmd.name == "test_cmd"
+ assert cmd.description == "A test command"
+ assert cmd.admin_only is True
+ assert cmd.permissions is not None
+
+ def test_command_decorator(self):
+ """Test command decorator functionality."""
+ cmd = Command("ping", "Ping command")
+
+ @cmd
+ def ping_func(ctx):
+ return "pong"
+
+ assert cmd.callback == ping_func
+ assert cmd.name == "ping"
+
+ def test_command_descriptor(self, test_bot):
+ """Test command descriptor functionality."""
+
+ class TestCog:
+ def __init__(self, bot):
+ self.bot = bot
+
+ @Command("cog_cmd", "Command from cog")
+ def cog_command(self, ctx):
+ ctx.reply("Cog response")
+
+ cog = TestCog(test_bot)
+ test_bot.add_cog(cog)
+
+ assert "cog_cmd" in test_bot.commands
+ cmd = test_bot.commands["cog_cmd"]
+ assert cmd.name == "cog_cmd"
+ # Just check that the callback exists and is callable
+ assert callable(cmd.callback)
+
+
+class TestSignatureSystem:
+ """Test cryptographic signature system."""
+
+ def test_signature_manager_creation(self, test_bot):
+ """Test signature manager is created properly."""
+ assert hasattr(test_bot, "signature_manager")
+ assert test_bot.signature_manager is not None
+
+ def test_signature_verification_disabled(self, test_bot):
+ """Test signature verification when disabled."""
+ # With verification disabled, should always return True
+ assert test_bot.signature_manager.verification_enabled is False
+ assert test_bot.signature_manager.should_verify_message("test") is False
+
+ def test_signature_verification_enabled(self, test_config_dir):
+ """Test signature verification when enabled."""
+ import uuid
+
+ unique_config_path = test_config_dir / f"secure_bot_{uuid.uuid4().hex[:8]}"
+ unique_config_path.mkdir(exist_ok=True)
+
+ # Create a unique identity for this test
+ test_identity = RNS.Identity()
+ identity_file = unique_config_path / "identity"
+ test_identity.to_file(str(identity_file))
+
+ # Temporarily replace the identity loading
+ original_from_file = RNS.Identity.from_file
+ RNS.Identity.from_file = lambda path: test_identity
+
+ try:
+ config = BotConfig(
+ name="SecureBot",
+ signature_verification_enabled=True,
+ permissions_enabled=True, # Enable permissions for this test
+ storage_path=str(unique_config_path / "storage"),
+ test_mode=True, # USE TEST MODE TO AVOID CRASH
+ )
+ bot = LXMFBot(**config.__dict__)
+ bot.config_path = str(unique_config_path)
+
+ assert bot.signature_manager.verification_enabled is True
+ # Test with a non-admin user (should require verification)
+ assert bot.signature_manager.should_verify_message("non_admin_user") is True
+
+ bot.cleanup()
+ finally:
+ # Restore original function
+ RNS.Identity.from_file = original_from_file
+
+
+class TestEventSystem:
+ """Test event system functionality."""
+
+ def test_event_creation(self):
+ """Test Event creation."""
+ from lxmfy.events import Event
+
+ event = Event("test_event", {"key": "value"})
+ assert event.name == "test_event"
+ assert event.data["key"] == "value"
+ assert event.cancelled is False
+
+ def test_event_cancellation(self):
+ """Test event cancellation."""
+ from lxmfy.events import Event
+
+ event = Event("test_event")
+ assert not event.cancelled
+
+ event.cancel()
+ assert event.cancelled
+
+ def test_event_manager(self, test_bot):
+ """Test event manager functionality."""
+ events_fired = []
+
+ @test_bot.events.on("test_event")
+ def test_handler(event):
+ events_fired.append(event.data)
+
+ from lxmfy.events import Event
+
+ test_event = Event("test_event", {"test": "data"})
+ test_bot.events.dispatch(test_event)
+
+ assert len(events_fired) == 1
+ assert events_fired[0]["test"] == "data"
+
+
+class TestStorageSystem:
+ """Test storage system functionality."""
+
+ def test_storage_initialization(self, test_bot):
+ """Test storage is initialized correctly."""
+ assert test_bot.storage is not None
+
+ def test_storage_operations(self, test_bot):
+ """Test basic storage operations."""
+ # Test set/get
+ test_bot.storage.set("test_key", {"data": "value"})
+ result = test_bot.storage.get("test_key")
+ assert result["data"] == "value"
+
+ # Test exists
+ assert test_bot.storage.exists("test_key")
+ assert not test_bot.storage.exists("nonexistent_key")
+
+ # Test scan
+ test_bot.storage.set("test_prefix_1", "value1")
+ test_bot.storage.set("test_prefix_2", "value2")
+ test_bot.storage.set("other_key", "value3")
+
+ results = test_bot.storage.scan("test_prefix_")
+ assert len(results) == 2
+ assert "test_prefix_1" in results
+ assert "test_prefix_2" in results
+
+ # Test delete
+ test_bot.storage.delete("test_key")
+ assert not test_bot.storage.exists("test_key")
+
+
+class TestPermissionSystem:
+ """Test permission system functionality."""
+
+ def test_permission_manager_creation(self, test_bot):
+ """Test permission manager is created."""
+ assert test_bot.permissions is not None
+ assert hasattr(test_bot.permissions, "enabled")
+ assert not test_bot.permissions.enabled # Disabled by default in tests
+
+ def test_permission_check_disabled(self, test_bot):
+ """Test permissions when system is disabled."""
+ # When disabled, all permissions should be granted
+ assert test_bot.permissions.has_permission("any_user", "any_perm")
+
+ def test_role_creation(self, test_config_dir):
+ """Test role creation and management."""
+ import uuid
+
+ from lxmfy.permissions import DefaultPerms
+
+ unique_config_path = test_config_dir / f"perm_bot_{uuid.uuid4().hex[:8]}"
+ unique_config_path.mkdir(exist_ok=True)
+
+ # Create a unique identity for this test
+ test_identity = RNS.Identity()
+ identity_file = unique_config_path / "identity"
+ test_identity.to_file(str(identity_file))
+
+ # Temporarily replace the identity loading
+ original_from_file = RNS.Identity.from_file
+ RNS.Identity.from_file = lambda path: test_identity
+
+ try:
+ config = BotConfig(
+ permissions_enabled=True,
+ storage_path=str(unique_config_path / "storage"),
+ test_mode=True, # USE TEST MODE TO AVOID CRASH
+ )
+ bot = LXMFBot(**config.__dict__)
+ bot.config_path = str(unique_config_path)
+
+ # Create a custom role
+ role = bot.permissions.create_role(
+ "moderator",
+ DefaultPerms.MANAGE_MESSAGES,
+ description="Can manage messages",
+ )
+
+ assert role.name == "moderator"
+ assert role.permissions == DefaultPerms.MANAGE_MESSAGES
+ assert role.description == "Can manage messages"
+
+ bot.cleanup()
+ finally:
+ # Restore original function
+ RNS.Identity.from_file = original_from_file

diff --git a/vendor/lxmfy/tests/test_e2e.py b/vendor/lxmfy/tests/test_e2e.py
new file mode 100644
index 00000000..88f4a7e6
--- /dev/null
+++ b/vendor/lxmfy/tests/test_e2e.py
@@ -0,0 +1,445 @@
+"""End-to-end tests for LXMFy CLI and full bot functionality."""
+
+import subprocess
+import tempfile
+import time
+from pathlib import Path
+
+from lxmfy import BotConfig, LXMFBot
+
+
+class TestCLIE2E:
+ """End-to-end tests for CLI functionality."""
+
+ def test_cli_create_basic_bot(self, test_config_dir):
+ """Test CLI bot creation."""
+ import sys
+
+ with tempfile.TemporaryDirectory() as temp_dir:
+ bot_path = Path(temp_dir) / "test_bot.py"
+
+ # Run CLI create command
+ import os
+
+ env = os.environ.copy()
+ env["PYTHONPATH"] = str(Path.cwd())
+
+ cmd = [
+ sys.executable,
+ "-m",
+ "lxmfy.cli",
+ "create",
+ "testbot",
+ "--output",
+ str(bot_path),
+ ]
+
+ result = subprocess.run(
+ cmd,
+ check=False,
+ capture_output=True,
+ text=True,
+ cwd=test_config_dir,
+ env=env,
+ )
+
+ assert result.returncode == 0
+ assert "Bot created successfully" in result.stdout
+ assert bot_path.exists()
+
+ # Verify bot file content
+ with open(bot_path) as f:
+ content = f.read()
+
+ assert "LXMFBot" in content
+ assert "testbot" in content
+
+ def test_cli_run_echo_bot(self, test_config_dir):
+ """Test CLI running echo bot template."""
+ # This test would start a bot process, but for CI we just verify
+ # the command doesn't fail immediately
+ import os
+ import sys
+
+ env = os.environ.copy()
+ env["PYTHONPATH"] = str(Path.cwd())
+
+ cmd = [
+ sys.executable,
+ "-m",
+ "lxmfy.cli",
+ "run",
+ "echo",
+ "--name",
+ "TestEchoBot",
+ ]
+
+ # Start the bot in a subprocess
+ process = subprocess.Popen(
+ cmd,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE,
+ cwd=test_config_dir,
+ text=True,
+ env=env,
+ )
+
+ # Let it run for a few seconds
+ time.sleep(3)
+
+ # Terminate the process
+ process.terminate()
+ try:
+ stdout, stderr = process.communicate(timeout=5)
+ except subprocess.TimeoutExpired:
+ process.kill()
+ stdout, stderr = process.communicate()
+
+ if process.returncode != 0 and process.returncode != -15:
+ print(f"STDOUT: {stdout}")
+ print(f"STDERR: {stderr}")
+
+ # Should have started without immediate errors
+ assert process.returncode == 0 or process.returncode == -15 # SIGTERM
+
+ def test_cli_signatures_test(self, test_config_dir):
+ """Test CLI signatures functionality."""
+ import os
+ import sys
+
+ env = os.environ.copy()
+ env["PYTHONPATH"] = str(Path.cwd())
+
+ cmd = [
+ sys.executable,
+ "-m",
+ "lxmfy.cli",
+ "signatures",
+ "test",
+ ]
+
+ result = subprocess.run(
+ cmd,
+ check=False,
+ capture_output=True,
+ text=True,
+ cwd=test_config_dir,
+ env=env,
+ )
+
+ # Should complete successfully
+ assert result.returncode == 0
+ assert "signature test" in result.stdout.lower()
+
+ def test_cli_signatures_enable_disable(self, test_config_dir):
+ """Test CLI signatures enable/disable instructions."""
+ import os
+ import sys
+
+ env = os.environ.copy()
+ env["PYTHONPATH"] = str(Path.cwd())
+
+ # Test enable command
+ cmd_enable = [
+ sys.executable,
+ "-m",
+ "lxmfy.cli",
+ "signatures",
+ "enable",
+ ]
+
+ result = subprocess.run(
+ cmd_enable,
+ check=False,
+ capture_output=True,
+ text=True,
+ cwd=test_config_dir,
+ env=env,
+ )
+
+ assert result.returncode == 0
+ assert "signature_verification_enabled=True" in result.stdout
+
+ # Test disable command
+ cmd_disable = [
+ "python",
+ "-m",
+ "lxmfy.cli",
+ "signatures",
+ "disable",
+ ]
+
+ result = subprocess.run(
+ cmd_disable,
+ check=False,
+ capture_output=True,
+ text=True,
+ cwd=test_config_dir,
+ env=env,
+ )
+
+ assert result.returncode == 0
+ assert "signature_verification_enabled=False" in result.stdout
+
+
+class TestFullBotLifecycle:
+ """Test complete bot lifecycle from creation to operation."""
+
+ def test_bot_creation_and_startup(self, test_config_dir):
+ """Test creating and starting a bot."""
+ config = BotConfig(
+ name="LifecycleTestBot",
+ announce=0, # Disable announcing
+ announce_enabled=False,
+ storage_path=str(test_config_dir / "lifecycle_storage"),
+ cogs_enabled=False,
+ permissions_enabled=False,
+ test_mode=True,
+ )
+
+ bot = LXMFBot(**config.__dict__)
+ bot.config_path = str(test_config_dir)
+
+ assert bot.config.name == "LifecycleTestBot"
+ assert bot.router is None # Should be None in test mode
+ assert bot.local is None # Should be None in test mode
+
+ # Test cleanup
+ bot.cleanup()
+
+ def test_bot_with_commands(self, test_config_dir):
+ """Test bot with custom commands."""
+ config = BotConfig(
+ name="CommandTestBot",
+ announce_enabled=False,
+ storage_path=str(test_config_dir / "command_storage"),
+ cogs_enabled=False,
+ test_mode=True,
+ )
+
+ bot = LXMFBot(**config.__dict__)
+ bot.config_path = str(test_config_dir)
+
+ # Add test commands
+ @bot.command("hello")
+ def hello_cmd(ctx):
+ ctx.reply("Hello from test bot!")
+
+ @bot.command("echo", admin_only=True)
+ def echo_cmd(ctx, message: str):
+ ctx.reply(message)
+
+ assert "hello" in bot.commands
+ assert "echo" in bot.commands
+
+ # Test command properties
+ echo_cmd_obj = bot.commands["echo"]
+ assert echo_cmd_obj.admin_only is True
+
+ bot.cleanup()
+
+ def test_bot_with_cogs(self, test_config_dir):
+ """Test bot with cog extensions."""
+ config = BotConfig(
+ name="CogTestBot",
+ announce_enabled=False,
+ storage_path=str(test_config_dir / "cog_storage"),
+ cogs_enabled=True,
+ cogs_dir=str(test_config_dir / "test_cogs"),
+ test_mode=True,
+ )
+
+ bot = LXMFBot(**config.__dict__)
+ bot.config_path = str(test_config_dir)
+
+ # Create test cog
+ cogs_dir = Path(test_config_dir) / "cogs"
+ cogs_dir.mkdir(exist_ok=True)
+
+ init_file = cogs_dir / "__init__.py"
+ init_file.write_text("")
+
+ cog_file = cogs_dir / "test_cog.py"
+ cog_file.write_text("""
+from lxmfy import Command
+
+class TestCog:
+ def __init__(self, bot):
+ self.bot = bot
+
+ @Command("cog_hello", "Hello from cog")
+ def cog_hello(self, ctx):
+ ctx.reply("Hello from cog!")
+
+def setup(bot):
+ bot.add_cog(TestCog(bot))
+""")
+
+ # Load cogs
+ from lxmfy import load_cogs_from_directory
+
+ load_cogs_from_directory(bot, "cogs")
+
+ assert "cog_hello" in bot.commands
+
+ bot.cleanup()
+
+ def test_bot_with_signatures(self, test_config_dir):
+ """Test bot with cryptographic signature verification."""
+ config = BotConfig(
+ name="SecureBot",
+ announce_enabled=False,
+ storage_path=str(test_config_dir / "secure_storage"),
+ signature_verification_enabled=True,
+ require_message_signatures=False,
+ test_mode=True,
+ )
+
+ bot = LXMFBot(**config.__dict__)
+ bot.config_path = str(test_config_dir)
+
+ assert bot.signature_manager.verification_enabled is True
+ assert bot.signature_manager.require_signatures is False
+
+ # Test signature manager functionality
+ assert bot.signature_manager.should_verify_message("test_user") is True
+
+ bot.cleanup()
+
+ def test_bot_with_permissions(self, test_config_dir):
+ """Test bot with permission system enabled."""
+ config = BotConfig(
+ name="PermBot",
+ announce_enabled=False,
+ storage_path=str(test_config_dir / "perm_storage"),
+ permissions_enabled=True,
+ test_mode=True,
+ )
+
+ bot = LXMFBot(**config.__dict__)
+ bot.config_path = str(test_config_dir)
+
+ assert bot.permissions.enabled is True
+
+ from lxmfy.permissions import DefaultPerms
+
+ # Test role creation
+ role = bot.permissions.create_role(
+ "moderator",
+ DefaultPerms.MANAGE_MESSAGES,
+ )
+ assert role.name == "moderator"
+
+ # Test user permission assignment
+ bot.permissions.assign_role("test_user", "moderator")
+ assert bot.permissions.has_permission("test_user", DefaultPerms.MANAGE_MESSAGES)
+
+ bot.cleanup()
+
+
+class TestTemplateBotOperations:
+ """Test that template bots can perform basic operations."""
+
+ def test_echo_bot_operations(self, test_config_dir):
+ """Test echo bot can handle commands."""
+ from lxmfy.templates import EchoBot
+
+ echo_bot = EchoBot(test_mode=True)
+
+ # Verify commands are registered
+ assert "echo" in echo_bot.bot.commands
+
+ # Mock a context for testing
+ class MockContext:
+ def __init__(self):
+ self.args = ["Hello", "World"]
+ self.content = "/echo Hello World"
+ self.sender = "test_sender"
+
+ def reply(self, message, **kwargs):
+ self.response = message
+
+ ctx = MockContext()
+
+ # Execute echo command
+ echo_cmd = echo_bot.bot.commands["echo"]
+ echo_cmd.callback(ctx)
+
+ # Verify response
+ assert hasattr(ctx, "response")
+ assert "Hello World" in ctx.response
+
+ echo_bot.bot.cleanup()
+
+ def test_note_bot_operations(self, test_config_dir):
+ """Test note bot can store and retrieve notes."""
+ from lxmfy.templates import NoteBot
+
+ note_bot = NoteBot(test_mode=True)
+
+ # Mock context for testing
+ class MockContext:
+ def __init__(self, sender="test_user"):
+ self.sender = sender
+ self.args = []
+
+ def reply(self, message):
+ self.responses = getattr(self, "responses", [])
+ self.responses.append(message)
+
+ # Test note saving
+ save_ctx = MockContext()
+ save_ctx.args = ["This", "is", "a", "test", "note"]
+ save_ctx.content = "/note This is a test note"
+
+ note_cmd = note_bot.bot.commands["note"]
+ note_cmd.callback(save_ctx)
+
+ assert len(save_ctx.responses) == 1
+ assert "saved" in save_ctx.responses[0].lower()
+
+ # Test note listing
+ list_ctx = MockContext()
+ list_cmd = note_bot.bot.commands["notes"]
+ list_cmd.callback(list_ctx)
+
+ assert len(list_ctx.responses) == 1
+ assert "test note" in list_ctx.responses[0]
+
+ note_bot.bot.cleanup()
+
+ def test_reminder_bot_operations(self, test_config_dir):
+ """Test reminder bot can set and list reminders."""
+ from lxmfy.templates import ReminderBot
+
+ reminder_bot = ReminderBot(test_mode=True)
+
+ class MockContext:
+ def __init__(self, sender="test_user"):
+ self.sender = sender
+ self.args = []
+
+ def reply(self, message):
+ self.responses = getattr(self, "responses", [])
+ self.responses.append(message)
+
+ # Test reminder setting
+ remind_ctx = MockContext()
+ remind_ctx.args = ["1h", "Test", "reminder"]
+ remind_ctx.content = "/remind 1h Test reminder"
+
+ remind_cmd = reminder_bot.bot.commands["remind"]
+ remind_cmd.callback(remind_ctx)
+
+ assert len(remind_ctx.responses) == 1
+ assert "remind" in remind_ctx.responses[0].lower()
+
+ # Test reminder listing
+ list_ctx = MockContext()
+ list_cmd = reminder_bot.bot.commands["list"]
+ list_cmd.callback(list_ctx)
+
+ assert len(list_ctx.responses) == 1
+ assert "reminders" in list_ctx.responses[0].lower()
+
+ reminder_bot.bot.cleanup()

diff --git a/vendor/lxmfy/tests/test_external_cogs.py b/vendor/lxmfy/tests/test_external_cogs.py
new file mode 100644
index 00000000..a5d8db22
--- /dev/null
+++ b/vendor/lxmfy/tests/test_external_cogs.py
@@ -0,0 +1,198 @@
+import os
+import stat
+import tempfile
+from pathlib import Path
+from unittest.mock import MagicMock, patch
+
+import pytest
+
+from lxmfy import BotConfig, LXMFBot
+from lxmfy.cogs_core import _get_sandbox_command, load_cogs_from_directory
+
+
+@pytest.fixture
+def external_cog_setup():
+ """Set up a temporary directory with an external script cog."""
+ with tempfile.TemporaryDirectory() as temp_dir:
+ temp_path = Path(temp_dir)
+ cogs_dir = temp_path / "cogs"
+ cogs_dir.mkdir()
+
+ # Create a simple bash script
+ script_path = cogs_dir / "test_cmd.sh"
+ script_content = """#!/bin/bash
+echo "Hello from external script! Sender: $1 Content: $2"
+"""
+ script_path.write_text(script_content)
+
+ # Make it executable
+ st = os.stat(script_path)
+ os.chmod(script_path, st.st_mode | stat.S_IEXEC)
+
+ yield temp_path
+
+
+def test_cog_external_loading(external_cog_setup):
+ """Test that external script cogs are loaded correctly."""
+ config = BotConfig(
+ name="TestBot",
+ test_mode=True,
+ external_cogs_enabled=True,
+ config_path=str(external_cog_setup),
+ storage_path=str(external_cog_setup / "storage"),
+ )
+ bot = LXMFBot(**config.__dict__)
+ bot.config_path = str(external_cog_setup)
+
+ # Load cogs
+ load_cogs_from_directory(bot)
+
+ assert "test_cmd" in bot.commands
+ cmd = bot.commands["test_cmd"]
+ assert cmd.name == "test_cmd"
+ assert cmd.threaded is True # Should always be threaded
+ assert "External script command" in cmd.description
+
+
+def test_cog_external_execution(external_cog_setup):
+ """Test that external script cogs execute and return output."""
+ config = BotConfig(
+ name="TestBot",
+ test_mode=True,
+ external_cogs_enabled=True,
+ external_cogs_sandbox_enabled=False,
+ config_path=str(external_cog_setup),
+ storage_path=str(external_cog_setup / "storage"),
+ )
+ bot = LXMFBot(**config.__dict__)
+ bot.config_path = str(external_cog_setup)
+
+ # Load cogs
+ load_cogs_from_directory(bot)
+
+ cmd = bot.commands["test_cmd"]
+
+ # Mock message
+ msg = MagicMock()
+ msg.sender = "test_sender"
+ msg.content = "/test_cmd hello world"
+ msg.args = ["hello", "world"]
+
+ # Execute callback
+ cmd.callback(msg)
+
+ # Check if msg.reply was called with expected output
+ msg.reply.assert_called_once()
+ args, _ = msg.reply.call_args
+ output = args[0]
+ assert "Hello from external script!" in output
+ assert "Sender: test_sender" in output
+ assert "Content: /test_cmd hello world" in output
+
+
+def test_cog_external_disabled(external_cog_setup):
+ """Test that external script cogs are NOT loaded when disabled."""
+ config = BotConfig(
+ name="TestBot",
+ test_mode=True,
+ external_cogs_enabled=False,
+ config_path=str(external_cog_setup),
+ storage_path=str(external_cog_setup / "storage"),
+ )
+ bot = LXMFBot(**config.__dict__)
+ bot.config_path = str(external_cog_setup)
+
+ # Load cogs
+ load_cogs_from_directory(bot)
+
+ assert "test_cmd" not in bot.commands
+
+
+def test_sandbox_detection_bwrap():
+ """Test bubblewrap sandbox detection."""
+ bot = MagicMock()
+ bot.config.external_cogs_sandbox_enabled = True
+ bot.config.external_cogs_sandbox_type = "auto"
+
+ with (
+ patch("shutil.which") as mock_which,
+ patch("sys.platform", "linux"),
+ patch("os.path.exists", return_value=True),
+ ):
+ mock_which.side_effect = lambda x: f"/usr/bin/{x}" if x == "bwrap" else None
+
+ cmd = _get_sandbox_command(bot, "/path/to/script")
+ assert cmd is not None
+ assert "/usr/bin/bwrap" in cmd
+
+
+def test_sandbox_detection_firejail():
+ """Test firejail sandbox detection."""
+ bot = MagicMock()
+ bot.config.external_cogs_sandbox_enabled = True
+ bot.config.external_cogs_sandbox_type = "auto"
+
+ with patch("shutil.which") as mock_which, patch("sys.platform", "linux"):
+ mock_which.side_effect = lambda x: f"/usr/bin/{x}" if x == "firejail" else None
+
+ cmd = _get_sandbox_command(bot, "/path/to/script")
+ assert cmd is not None
+ assert "/usr/bin/firejail" in cmd
+
+
+def test_cog_external_timeout_enforcement(external_cog_setup):
+ """Test that external script cogs time out correctly."""
+ # Create a script that sleeps forever
+ cogs_dir = external_cog_setup / "cogs"
+ timeout_script = cogs_dir / "timeout.sh"
+ timeout_script.write_text("#!/bin/bash\nsleep 10")
+ os.chmod(timeout_script, os.stat(timeout_script).st_mode | stat.S_IEXEC)
+
+ config = BotConfig(
+ name="TestBot",
+ test_mode=True,
+ external_cogs_enabled=True,
+ external_cogs_sandbox_enabled=False,
+ external_cogs_timeout=1, # 1 second timeout
+ config_path=str(external_cog_setup),
+ storage_path=str(external_cog_setup / "storage"),
+ )
+ bot = LXMFBot(**config.__dict__)
+ bot.config_path = str(external_cog_setup)
+
+ load_cogs_from_directory(bot)
+ cmd = bot.commands["timeout"]
+
+ msg = MagicMock()
+ cmd.callback(msg)
+
+ msg.reply.assert_called_with("Error: Command timeout.sh timed out.")
+
+
+def test_cog_external_timeout_disabled(external_cog_setup):
+ """Test that external script cogs don't time out when timeout is 0."""
+ # Create a script that sleeps for 2 seconds
+ cogs_dir = external_cog_setup / "cogs"
+ sleep_script = cogs_dir / "sleep.sh"
+ sleep_script.write_text("#!/bin/bash\nsleep 1.5\necho 'Done sleeping'")
+ os.chmod(sleep_script, os.stat(sleep_script).st_mode | stat.S_IEXEC)
+
+ config = BotConfig(
+ name="TestBot",
+ test_mode=True,
+ external_cogs_enabled=True,
+ external_cogs_sandbox_enabled=False,
+ external_cogs_timeout=0, # Infinite timeout
+ config_path=str(external_cog_setup),
+ storage_path=str(external_cog_setup / "storage"),
+ )
+ bot = LXMFBot(**config.__dict__)
+ bot.config_path = str(external_cog_setup)
+
+ load_cogs_from_directory(bot)
+ cmd = bot.commands["sleep"]
+
+ msg = MagicMock()
+ cmd.callback(msg)
+
+ msg.reply.assert_called_with("Done sleeping")

diff --git a/vendor/lxmfy/tests/test_fsm_config.py b/vendor/lxmfy/tests/test_fsm_config.py
new file mode 100644
index 00000000..14aab1ce
--- /dev/null
+++ b/vendor/lxmfy/tests/test_fsm_config.py
@@ -0,0 +1,89 @@
+"""Combinatorial and FSM testing for framework configuration."""
+
+import pytest
+import itertools
+from unittest.mock import MagicMock
+from lxmfy import BotConfig, LXMFBot
+
+
+class TestFrameworkPermutations:
+ """Combinatorial testing of bot configurations."""
+
+ def test_pairwise_config_initialization(self, test_config_dir):
+ """Test a matrix of configuration settings to ensure no conflicting states."""
+
+ # Define dimensions of our configuration space
+ options = {
+ "cogs_enabled": [True, False],
+ "nlp_enabled": [True, False],
+ "permissions_enabled": [True, False],
+ "signature_verification_enabled": [True, False],
+ "storage_type": ["json", "sqlite"],
+ }
+
+ # Get all permutations (Cartesian product)
+ # Note: In a real "ALLLLL" test we use all, but pairwise is smarter
+ keys = options.keys()
+ values = options.values()
+
+ count = 0
+ for combination in itertools.product(*values):
+ config_dict = dict(zip(keys, combination))
+
+ # Add required fields
+ config_dict["name"] = f"TestBot_{count}"
+ config_dict["storage_path"] = str(test_config_dir / f"config_test_{count}")
+ config_dict["test_mode"] = True
+
+ try:
+ bot = LXMFBot(**config_dict)
+ assert bot is not None
+ # Basic sanity check of initialized components
+ if config_dict["nlp_enabled"]:
+ assert bot.nlp is not None
+ if config_dict["permissions_enabled"]:
+ assert bot.permissions.enabled is True
+
+ bot.cleanup()
+ count += 1
+ except Exception as e:
+ pytest.fail(
+ f"Bot failed to initialize with combination {config_dict}: {e}"
+ )
+
+ print(
+ f"\n[Combinatorial] Successfully verified {count} configuration permutations."
+ )
+
+ def test_fsm_message_lifecycle_transitions(self, test_config_dir):
+ """Verify the Finite State Machine transitions of a message."""
+ config = BotConfig(
+ name="FSMBot",
+ storage_path=str(test_config_dir / "fsm_storage"),
+ test_mode=True,
+ )
+ bot = LXMFBot(**config.__dict__)
+
+ # Define states for our "Message"
+ # State: QUEUED -> (SENT | FAILED)
+
+ message_content = "FSM Test"
+ dest = "abc123def"
+
+ # 1. Transition: None -> QUEUED
+ bot.send(dest, message_content)
+ assert bot.queue.qsize() == 1
+
+ # Peek at the message
+ msg = bot.queue.queue[0]
+ assert msg.content.decode() == message_content
+
+ # 2. Transition: QUEUED -> SENT (Simulated by processing queue)
+ bot.router = MagicMock()
+ # Non-blocking run-once of the queue processing logic
+ while not bot.queue.empty():
+ lxm = bot.queue.get()
+ bot.router.handle_outbound(lxm)
+
+ assert bot.queue.qsize() == 0
+ bot.router.handle_outbound.assert_called_once()

diff --git a/vendor/lxmfy/tests/test_hypothesis_fuzzing.py b/vendor/lxmfy/tests/test_hypothesis_fuzzing.py
new file mode 100644
index 00000000..a5ff08d9
--- /dev/null
+++ b/vendor/lxmfy/tests/test_hypothesis_fuzzing.py
@@ -0,0 +1,26 @@
+import pytest
+from hypothesis import given
+from hypothesis import strategies as st
+
+from lxmfy.attachments import Attachment, AttachmentType, pack_attachment
+
+
+class TestAttachmentFuzzing:
+ """Fuzz testing for attachment binary parsing and packing."""
+
+ @given(
+ type=st.sampled_from(list(AttachmentType)),
+ name=st.text(min_size=0, max_size=255),
+ data=st.binary(min_size=0, max_size=10000),
+ format=st.text(min_size=0, max_size=50),
+ )
+ def test_pack_attachment_robustness(self, type, name, data, format):
+ """Test that packing an attachment with any binary data never crashes."""
+ att = Attachment(type=type, name=name, data=data, format=format)
+ try:
+ fields = pack_attachment(att)
+ assert isinstance(fields, dict)
+ # Ensure keys are integers for LXMF compatibility
+ assert all(isinstance(k, int) for k in fields.keys())
+ except Exception as e:
+ pytest.fail(f"pack_attachment crashed with {type}, {name}: {e}")

diff --git a/vendor/lxmfy/tests/test_hypothesis_middleware.py b/vendor/lxmfy/tests/test_hypothesis_middleware.py
new file mode 100644
index 00000000..ee56bac5
--- /dev/null
+++ b/vendor/lxmfy/tests/test_hypothesis_middleware.py
@@ -0,0 +1,33 @@
+from hypothesis import given
+from hypothesis import strategies as st
+
+from lxmfy.middleware import MessageTracker
+
+
+class TestMiddlewarePropertyBased:
+ """Property-based tests for middleware utilities."""
+
+ @given(
+ hashes=st.lists(st.text(min_size=1), min_size=1, max_size=200),
+ max_size=st.integers(min_value=10, max_value=50),
+ )
+ def test_message_tracker_pruning(self, hashes, max_size):
+ """Test that MessageTracker correctly prunes old hashes and tracks new ones."""
+ tracker = MessageTracker(max_size=max_size)
+
+ for h in hashes:
+ tracker.is_processed(h)
+
+ assert len(tracker.processed_set) <= max_size
+
+ # Last added items should generally be in the set
+ last_item = hashes[-1]
+ assert last_item in tracker.processed_set
+
+ @given(h=st.text(min_size=1))
+ def test_message_tracker_idempotence(self, h):
+ """Test that the first call returns False and subsequent calls return True."""
+ tracker = MessageTracker()
+ assert tracker.is_processed(h) is False
+ assert tracker.is_processed(h) is True
+ assert tracker.is_processed(h) is True

diff --git a/vendor/lxmfy/tests/test_hypothesis_parsing.py b/vendor/lxmfy/tests/test_hypothesis_parsing.py
new file mode 100644
index 00000000..22b27e47
--- /dev/null
+++ b/vendor/lxmfy/tests/test_hypothesis_parsing.py
@@ -0,0 +1,78 @@
+from hypothesis import given
+from hypothesis import strategies as st
+
+
+# We want to test the parsing logic found in core.py _process_message
+def simulate_parsing(content, command_prefix):
+ """A standalone implementation of the parsing logic in core.py."""
+ if not content:
+ return None, []
+
+ if command_prefix is None or content.startswith(command_prefix):
+ try:
+ parts = content.split()
+ if not parts:
+ return None, []
+
+ if command_prefix:
+ command_name = parts[0][len(command_prefix) :]
+ else:
+ command_name = parts[0]
+
+ args = parts[1:]
+ return command_name, args
+ except Exception:
+ return None, []
+ return None, []
+
+
+class TestParsingPropertyBased:
+ """Property-based tests for command and message parsing."""
+
+ @given(
+ prefix=st.text(min_size=1, max_size=5).filter(
+ lambda x: not any(s.isspace() for s in x),
+ ),
+ cmd=st.text(min_size=1, max_size=20).filter(
+ lambda x: not any(s.isspace() for s in x),
+ ),
+ args=st.lists(
+ st.text(min_size=1, max_size=20).filter(
+ lambda x: not any(s.isspace() for s in x),
+ ),
+ min_size=0,
+ max_size=10,
+ ),
+ )
+ def test_command_parsing_structured(self, prefix, cmd, args):
+ """Test that well-formed commands are always parsed correctly."""
+ content = prefix + cmd + " " + " ".join(args)
+ parsed_cmd, parsed_args = simulate_parsing(content.strip(), prefix)
+
+ assert parsed_cmd == cmd
+ assert parsed_args == args
+
+ @given(
+ prefix=st.one_of(st.none(), st.text(max_size=5)),
+ content=st.text(max_size=200),
+ )
+ def test_parsing_never_crashes(self, prefix, content):
+ """Test that the parsing logic is robust against any string input."""
+ simulate_parsing(content, prefix)
+
+ @given(
+ cmd=st.text(min_size=1).filter(lambda x: not any(s.isspace() for s in x)),
+ args=st.lists(
+ st.text(min_size=1).filter(lambda x: not any(s.isspace() for s in x)),
+ ),
+ )
+ def test_no_prefix_parsing(self, cmd, args):
+ """Test parsing when no prefix is configured."""
+ content = cmd + " " + " ".join(args)
+ parsed_cmd, parsed_args = simulate_parsing(content.strip(), None)
+
+ if content.strip():
+ assert parsed_cmd == cmd
+ assert parsed_args == args
+ else:
+ assert parsed_cmd is None

diff --git a/vendor/lxmfy/tests/test_hypothesis_permissions.py b/vendor/lxmfy/tests/test_hypothesis_permissions.py
new file mode 100644
index 00000000..05bf83e4
--- /dev/null
+++ b/vendor/lxmfy/tests/test_hypothesis_permissions.py
@@ -0,0 +1,84 @@
+from unittest import mock
+
+from hypothesis import given
+from hypothesis import strategies as st
+
+from lxmfy.permissions import DefaultPerms, PermissionManager, Role
+
+
+class TestPermissionsPropertyBased:
+ """Property-based tests for the permissions system."""
+
+ @given(
+ p1=st.sampled_from(list(DefaultPerms)),
+ p2=st.sampled_from(list(DefaultPerms)),
+ )
+ def test_perms_bitwise_commutative(self, p1, p2):
+ """Test that ORing permissions is commutative."""
+ assert (p1 | p2) == (p2 | p1)
+
+ @given(p=st.sampled_from(list(DefaultPerms)))
+ def test_perms_bitwise_idempotent(self, p):
+ """Test that ORing a permission with itself is idempotent."""
+ assert (p | p) == p
+
+ @given(perms_list=st.lists(st.sampled_from(list(DefaultPerms)), min_size=1))
+ def test_perms_all_contain_individual(self, perms_list):
+ """Test that a combined permission set contains all its components."""
+ combined = DefaultPerms.NONE
+ for p in perms_list:
+ combined |= p
+
+ for p in perms_list:
+ assert (combined & p) == p
+
+ @st.composite
+ def perms_strategy(draw):
+ """Strategy to generate a combined DefaultPerms flag."""
+ flags = draw(st.lists(st.sampled_from(list(DefaultPerms)), min_size=1))
+ combined = DefaultPerms.NONE
+ for f in flags:
+ combined |= f
+ return combined
+
+ @given(
+ user_id=st.text(min_size=1),
+ roles_data=st.dictionaries(
+ st.text(min_size=1, max_size=10).filter(
+ lambda x: x not in ["user", "admin"],
+ ),
+ perms_strategy(),
+ ),
+ )
+ def test_pm_complex_aggregation(self, user_id, roles_data):
+ """Test that PermissionManager correctly aggregates multiple complex roles."""
+ storage = mock.MagicMock()
+ storage.get.return_value = {}
+ pm = PermissionManager(storage=storage, enabled=True)
+
+ expected_perms = pm.default_role.permissions
+
+ # Manually assign default role for consistency with PM behavior
+ pm.user_roles[user_id] = {pm.default_role.name}
+
+ for name, perms in roles_data.items():
+ pm.roles[name] = Role(name, perms)
+ pm.assign_role(user_id, name)
+ expected_perms |= perms
+
+ assert pm.get_user_permissions(user_id) == expected_perms
+
+ # Test individual permission checks
+ for name, perms in roles_data.items():
+ # For each flag in the combined perms, it should return True
+ for flag in DefaultPerms:
+ if flag != DefaultPerms.NONE and (perms & flag) == flag:
+ assert pm.has_permission(user_id, flag) is True
+
+ @given(p=perms_strategy())
+ def test_pm_disabled_always_allows(self, p):
+ """Test that when PM is disabled, has_permission always returns True."""
+ storage = mock.MagicMock()
+ storage.get.return_value = {}
+ pm = PermissionManager(storage=storage, enabled=False)
+ assert pm.has_permission("any_user", p) is True

diff --git a/vendor/lxmfy/tests/test_hypothesis_security.py b/vendor/lxmfy/tests/test_hypothesis_security.py
new file mode 100644
index 00000000..809c2de4
--- /dev/null
+++ b/vendor/lxmfy/tests/test_hypothesis_security.py
@@ -0,0 +1,101 @@
+from unittest.mock import MagicMock
+
+import pytest
+import RNS
+from hypothesis import given
+from hypothesis import strategies as st
+
+from lxmfy import BotConfig, LXMFBot
+
+
+def test_identity_pinning_collision_resilience(test_config_dir):
+ """Verify that identity pinning resists key collisions for the same hash."""
+ # 1. Setup bot with pinning and isolated storage
+ import uuid
+
+ storage_path = str(test_config_dir / f"pin_test_{uuid.uuid4().hex}")
+ config = BotConfig(
+ identity_pinning_enabled=True,
+ test_mode=True,
+ storage_path=storage_path,
+ config_path=storage_path,
+ )
+ bot = LXMFBot(**config.__dict__)
+
+ # 2. First owner seen for a hash
+ sender_hash = "abc123def456"
+ owner_identity = RNS.Identity()
+
+ # Simulate first message verification (pins the identity)
+ bot.signature_manager.verify_message_signature(
+ MagicMock(),
+ b"valid_sig",
+ sender_hash,
+ sender_identity=owner_identity,
+ )
+
+ # Verify it was pinned
+ pinned_key = bot.storage.get(f"pin:{sender_hash}")
+ assert pinned_key == owner_identity.get_public_key()
+
+ # 3. Attacker with DIFFERENT key but same hash (simulated)
+ attacker_identity = RNS.Identity()
+ assert attacker_identity.get_public_key() != owner_identity.get_public_key()
+
+ # Try to verify with attacker key for the same hash
+ # Our pinning layer should reject it
+ is_valid = bot.signature_manager.verify_message_signature(
+ MagicMock(),
+ b"attacker_sig",
+ sender_hash,
+ sender_identity=attacker_identity,
+ )
+
+ assert is_valid is False, (
+ "Pinning failed to reject a different key for the same hash!"
+ )
+
+
+@given(st.binary(min_size=64, max_size=64))
+def test_signature_malleability_hypothesis(mutated_sig):
+ """Test that mutated signatures are never accepted."""
+ config = BotConfig(signature_verification_enabled=True, test_mode=True)
+ bot = LXMFBot(**config.__dict__)
+
+ sender_hash = "test_sender"
+ identity = RNS.Identity()
+
+ # Any random mutation of a signature should fail validation
+ # (Assuming the canonical message data is held constant)
+ is_valid = bot.signature_manager.verify_message_signature(
+ MagicMock(),
+ mutated_sig,
+ sender_hash,
+ sender_identity=identity,
+ )
+
+ # Valid validation is statistically impossible with random bytes
+ assert is_valid is False
+
+
+@pytest.fixture(scope="module")
+def trained_nlp():
+ """Shared trained NLP instance for fuzzing."""
+ from lxmfy.nlp import IntentClassifier
+
+ nlp = IntentClassifier(threshold=0.5)
+ nlp.add_intent("test", ["hello world", "how are you"], train=True)
+ return nlp
+
+
+@given(st.text(min_size=1, max_size=500))
+def test_intent_classification_fuzzing(trained_nlp, input_text):
+ """Fuzz the intent classifier with arbitrary strings."""
+ # Should never crash regardless of input
+ try:
+ intent, score = trained_nlp.predict(input_text)
+ assert 0.0 <= score <= 1.0
+ except Exception as e:
+ pytest.fail(
+ f"Intent classifier crashed on input: {input_text!r} with error: {e}",
+ )

diff --git a/vendor/lxmfy/tests/test_hypothesis_signatures.py b/vendor/lxmfy/tests/test_hypothesis_signatures.py
new file mode 100644
index 00000000..4d94b662
--- /dev/null
+++ b/vendor/lxmfy/tests/test_hypothesis_signatures.py
@@ -0,0 +1,86 @@
+from unittest import mock
+
+import RNS
+from hypothesis import given
+from hypothesis import strategies as st
+
+from lxmfy.signatures import SignatureManager
+
+FIELD_SIGNATURE = 0xFA
+
+
+class TestSignaturePropertyBased:
+ """Property-based tests for SignatureManager."""
+
+ @given(
+ source_hash=st.binary(min_size=0, max_size=100),
+ dest_hash=st.binary(min_size=0, max_size=100),
+ content=st.one_of(st.binary(min_size=0, max_size=1000), st.none()),
+ title=st.one_of(st.binary(min_size=0, max_size=200), st.none()),
+ timestamp=st.one_of(st.integers(min_value=0, max_value=2**32 - 1), st.none()),
+ fields=st.dictionaries(
+ st.integers(min_value=1, max_value=255),
+ st.binary(min_size=0, max_size=100),
+ ),
+ )
+ def test_canonicalize_roundtrip_consistency(
+ self,
+ source_hash,
+ dest_hash,
+ content,
+ title,
+ timestamp,
+ fields,
+ ):
+ """Test that canonicalization is consistent and handles arbitrary data."""
+ bot = mock.MagicMock()
+ sig_manager = SignatureManager(bot)
+
+ mock_message = mock.MagicMock()
+ mock_message.source_hash = source_hash
+ mock_message.destination_hash = dest_hash
+ mock_message.content = content
+ mock_message.title = title
+ mock_message.timestamp = timestamp
+ # Filter out FIELD_SIGNATURE if it happens to be in generated fields
+ mock_message.fields = {k: v for k, v in fields.items() if k != FIELD_SIGNATURE}
+
+ result1 = sig_manager._canonicalize_message(mock_message)
+ result2 = sig_manager._canonicalize_message(mock_message)
+
+ # Determinism check
+ assert result1 == result2
+ assert isinstance(result1, bytes)
+
+ @given(
+ content=st.binary(min_size=1, max_size=500),
+ title=st.binary(min_size=0, max_size=100),
+ )
+ def test_signature_verification_property(self, content, title):
+ """Test that a signed message always verifies with the correct identity."""
+ bot = mock.MagicMock()
+ bot.config.identity_pinning_enabled = False # Disable for this test
+ sig_manager = SignatureManager(bot)
+ identity = RNS.Identity()
+
+ mock_message = mock.MagicMock()
+ mock_message.source_hash = b"source"
+ mock_message.destination_hash = b"dest"
+ mock_message.content = content
+ mock_message.title = title
+ mock_message.timestamp = 123456789
+ mock_message.fields = {}
+
+ signature = sig_manager.sign_message(mock_message, identity)
+ mock_message.fields[FIELD_SIGNATURE] = signature
+
+ sender_hash = RNS.hexrep(identity.hash, delimit=False)
+ assert (
+ sig_manager.verify_message_signature(
+ mock_message,
+ signature,
+ sender_hash,
+ identity,
+ )
+ is True
+ )

diff --git a/vendor/lxmfy/tests/test_hypothesis_storage.py b/vendor/lxmfy/tests/test_hypothesis_storage.py
new file mode 100644
index 00000000..d7737089
--- /dev/null
+++ b/vendor/lxmfy/tests/test_hypothesis_storage.py
@@ -0,0 +1,63 @@
+from datetime import datetime
+
+from hypothesis import given
+from hypothesis import strategies as st
+
+from lxmfy.storage import Attachment, AttachmentType, deserialize_value, serialize_value
+
+
+class TestStoragePropertyBased:
+ """Property-based tests for storage serialization."""
+
+ # Strategy for nested JSON-like data with bytes and datetime
+ @st.composite
+ def serializable_strategy(draw):
+ """Generates data that LXMFy storage should be able to serialize."""
+ return draw(
+ st.recursive(
+ st.one_of(
+ st.none(),
+ st.booleans(),
+ st.integers(),
+ st.floats(allow_nan=False, allow_infinity=False),
+ st.text(),
+ st.binary(),
+ st.datetimes(
+ max_value=datetime(2100, 1, 1),
+ min_value=datetime(1970, 1, 1),
+ ),
+ ),
+ lambda children: st.one_of(
+ st.lists(children),
+ st.dictionaries(st.text(), children),
+ ),
+ ),
+ )
+
+ @given(data=serializable_strategy())
+ def test_serialization_roundtrip(self, data):
+ """Test that serialize/deserialize is a lossless roundtrip for supported types."""
+ serialized = serialize_value(data)
+ deserialized = deserialize_value(serialized)
+
+ # Datetime might lose microsecond precision depending on isoformat, but fromisoformat handles it
+ # Actually, LXMFy uses isoformat() which is good.
+ assert deserialized == data
+
+ @given(
+ type=st.sampled_from(list(AttachmentType)),
+ name=st.text(min_size=1),
+ data=st.binary(),
+ format=st.text(),
+ )
+ def test_attachment_roundtrip(self, type, name, data, format):
+ """Test Attachment serialization roundtrip."""
+ att = Attachment(type=type, name=name, data=data, format=format)
+ serialized = serialize_value(att)
+ deserialized = deserialize_value(serialized)
+
+ assert isinstance(deserialized, Attachment)
+ assert deserialized.type == att.type
+ assert deserialized.name == att.name
+ assert deserialized.data == att.data
+ assert deserialized.format == att.format

diff --git a/vendor/lxmfy/tests/test_hypothesis_validation.py b/vendor/lxmfy/tests/test_hypothesis_validation.py
new file mode 100644
index 00000000..3c80d1a7
--- /dev/null
+++ b/vendor/lxmfy/tests/test_hypothesis_validation.py
@@ -0,0 +1,50 @@
+from hypothesis import given
+from hypothesis import strategies as st
+
+from lxmfy.validation import ConfigValidator, ValidationResult
+
+
+class ConfigObject:
+ """A simple class to hold configuration attributes."""
+
+ def __init__(self, **kwargs):
+ for k, v in kwargs.items():
+ setattr(self, k, v)
+
+
+class TestValidationPropertyBased:
+ """Property-based tests for bot configuration validation."""
+
+ @st.composite
+ def config_strategy(draw):
+ """Strategy for generating bot configuration objects."""
+ return ConfigObject(
+ name=draw(st.text()),
+ announce=draw(st.integers(min_value=-1000, max_value=10000)),
+ rate_limit=draw(st.integers(min_value=-100, max_value=1000)),
+ cooldown=draw(st.integers(min_value=-100, max_value=1000)),
+ )
+
+ @given(config=config_strategy())
+ def test_config_validation_robustness(self, config):
+ """Test that validation never crashes and returns expected result types."""
+ results = ConfigValidator.validate_config(config)
+ assert isinstance(results, list)
+ for res in results:
+ assert isinstance(res, ValidationResult)
+ assert isinstance(res.valid, bool)
+ assert isinstance(res.messages, list)
+ assert all(isinstance(m, str) for m in res.messages)
+
+ @given(name=st.text(min_size=0, max_size=2))
+ def test_short_name_invalid(self, name):
+ """Test that names shorter than 3 characters always trigger an error."""
+ config = ConfigObject(name=name, announce=300, rate_limit=5, cooldown=30)
+ results = ConfigValidator.validate_config(config)
+
+ errors = [r for r in results if not r.valid and r.severity == "error"]
+ assert any(
+ "Bot name should be at least 3 characters long" in m
+ for r in errors
+ for m in r.messages
+ )

diff --git a/vendor/lxmfy/tests/test_integration.py b/vendor/lxmfy/tests/test_integration.py
new file mode 100644
index 00000000..c93d9cf6
--- /dev/null
+++ b/vendor/lxmfy/tests/test_integration.py
@@ -0,0 +1,301 @@
+"""Integration tests for LXMFy client-bot communication."""
+
+from unittest.mock import Mock
+
+import RNS
+from LXMF import LXMessage
+
+
+class TestClientBotCommunication:
+ """Test client-bot message exchange."""
+
+ def test_message_sending(self, test_bot, test_destination):
+ """Test basic message sending functionality."""
+ # Mock the queue.put to capture queued messages
+ original_queue_put = test_bot.queue.put
+ queued_messages = []
+
+ def capture_queue_put(message):
+ queued_messages.append(message)
+ return original_queue_put(message)
+
+ test_bot.queue.put = capture_queue_put
+
+ # Mock Identity.recall to return the test identity so send() works
+ original_recall = RNS.Identity.recall
+ RNS.Identity.recall = lambda hash_bytes: (
+ test_destination.identity
+ if hash_bytes == test_destination.hash
+ else original_recall(hash_bytes)
+ )
+
+ try:
+ # Send a message using the test destination's hash
+ dest_hash = RNS.hexrep(test_destination.hash, delimit=False)
+ test_bot.send(dest_hash, "Hello World", "Test Title")
+
+ # Verify message was queued
+ assert len(queued_messages) == 1
+ message = queued_messages[0]
+
+ # In test mode, message is a SimpleNamespace, not LXMessage
+ if test_bot.config.test_mode:
+ assert message.content.decode() == "Hello World"
+ assert message.title.decode() == "Test Title"
+ else:
+ assert isinstance(message, LXMessage)
+ assert message.content.decode() == "Hello World"
+ assert message.title.decode() == "Test Title"
+ finally:
+ # Restore original methods
+ test_bot.queue.put = original_queue_put
+ RNS.Identity.recall = original_recall
+
+ def test_command_processing(self, test_bot):
+ """Test command processing pipeline."""
+ # Register a test command
+ responses = []
+
+ @test_bot.command("test")
+ def test_cmd(ctx):
+ responses.append(f"Processed: {ctx.content}")
+ ctx.reply("Command executed")
+
+ # Mock the send method to capture responses
+ original_send = test_bot.send
+ sent_messages = []
+
+ def mock_send(destination, message, title=None, **kwargs):
+ sent_messages.append((destination, message, title))
+
+ test_bot.send = mock_send
+
+ # Simulate receiving a command message
+ mock_message = Mock()
+ mock_message.content = b"/test argument"
+ mock_message.hash = b"message_hash_123"
+
+ # Process the message
+ test_bot._process_message(mock_message, "test_sender_hash")
+
+ # Verify command was processed
+ assert len(responses) == 1
+ assert "Processed: /test argument" in responses[0]
+
+ assert len(sent_messages) == 1
+ dest, msg, title = sent_messages[0]
+ assert dest == "test_sender_hash"
+ assert msg == "Command executed"
+
+ # Restore original send method
+ test_bot.send = original_send
+
+ def test_spam_protection(self, test_bot):
+ """Test spam protection functionality."""
+ sender = "spam_sender_hash"
+
+ # Initially should allow messages
+ allowed, message = test_bot.spam_protection.check_spam(sender)
+ assert allowed
+ assert message is None
+
+ # Test that spam protection exists and has expected attributes
+ assert hasattr(test_bot, "spam_protection")
+ assert hasattr(test_bot.spam_protection, "check_spam")
+ assert callable(test_bot.spam_protection.check_spam)
+
+ # Test multiple messages - should eventually trigger protection
+ # (exact behavior depends on timing, but method should exist)
+ for i in range(5): # Just test a few messages
+ result_allowed, result_message = test_bot.spam_protection.check_spam(sender)
+ # Should either allow or deny, but not crash
+ assert isinstance(result_allowed, bool)
+ if result_message:
+ assert isinstance(result_message, str)
+
+ def test_message_validation(self, test_bot):
+ """Test message validation and processing."""
+ # Test with various message formats
+ test_cases = [
+ (b"/help", True, "help command"),
+ (b"regular message", True, "regular message"),
+ (b"", True, "empty message"),
+ (b"/nonexistent", True, "nonexistent command"),
+ ]
+
+ for content, should_process, description in test_cases:
+ mock_message = Mock()
+ mock_message.content = content
+ mock_message.source_hash = b"test_hash"
+
+ # Should not raise exceptions
+ try:
+ test_bot._process_message(mock_message, "test_hash")
+ success = True
+ except Exception as e:
+ success = False
+ print(f"Failed processing {description}: {e}")
+
+ assert success, f"Failed to process {description}"
+
+
+class TestLXMFIntegration:
+ """Test LXMF-specific integration."""
+
+ def test_lxmf_message_creation(self, lxmf_router):
+ """Test LXMF message creation and basic properties."""
+ # Create a test message
+ message = LXMessage(
+ destination=lxmf_router._test_delivery_dest,
+ source=lxmf_router._test_delivery_dest,
+ content=b"Test content",
+ title=b"Test Title",
+ )
+
+ assert message.content == b"Test content"
+ assert message.title == b"Test Title"
+ assert message.source_hash is not None
+ assert message.destination_hash is not None
+
+ def test_router_functionality(self, lxmf_router):
+ """Test LXMF router basic functionality."""
+ assert lxmf_router._test_delivery_dest is not None
+ assert lxmf_router.storagepath is not None
+
+ # Test router can handle outbound messages
+ message = LXMessage(
+ destination=lxmf_router._test_delivery_dest,
+ source=lxmf_router._test_delivery_dest,
+ content=b"Test message",
+ )
+
+ # Should not raise exceptions
+ lxmf_router.handle_outbound(message)
+
+
+class TestTemplateBots:
+ """Test the built-in template bots."""
+
+ def test_echo_bot_creation(self, test_config_dir):
+ """Test creating an echo bot template."""
+ from lxmfy.templates import EchoBot
+
+ # Create echo bot instance
+ echo_bot = EchoBot(test_mode=True)
+
+ assert echo_bot.bot is not None
+ assert echo_bot.bot.config.name == "Echo Bot"
+ assert "echo" in echo_bot.bot.commands
+
+ # Test echo command exists
+ echo_cmd = echo_bot.bot.commands["echo"]
+ assert echo_cmd.name == "echo"
+ assert "Echo back your message" in echo_cmd.description
+
+ echo_bot.bot.cleanup()
+
+ def test_note_bot_creation(self, test_config_dir):
+ """Test creating a note bot template."""
+ from lxmfy.templates import NoteBot
+
+ note_bot = NoteBot(test_mode=True)
+
+ assert note_bot.bot is not None
+ assert note_bot.bot.config.name == "Note Bot"
+ assert "note" in note_bot.bot.commands
+ assert "notes" in note_bot.bot.commands
+
+ note_bot.bot.cleanup()
+
+ def test_reminder_bot_creation(self, test_config_dir):
+ """Test creating a reminder bot template."""
+ from lxmfy.templates import ReminderBot
+
+ reminder_bot = ReminderBot(test_mode=True)
+
+ assert reminder_bot.bot is not None
+ assert reminder_bot.bot.config.name == "Reminder Bot"
+ assert "remind" in reminder_bot.bot.commands
+ assert "list" in reminder_bot.bot.commands
+
+ reminder_bot.bot.cleanup()
+
+ def test_cog_test_bot_creation(self, test_config_dir):
+ """Test creating a cog test bot template."""
+ from lxmfy.templates import CogTestBot
+
+ cog_bot = CogTestBot(test_mode=True)
+
+ assert cog_bot.bot is not None
+ assert cog_bot.bot.config.name == "CogTestBot"
+ assert "cogtest" in cog_bot.bot.commands
+ assert "status" in cog_bot.bot.commands
+
+ cog_bot.bot.cleanup()
+
+
+class TestMiddlewareSystem:
+ """Test middleware system integration."""
+
+ def test_middleware_registration(self, test_bot):
+ """Test middleware can be registered and executed."""
+ from lxmfy.middleware import MiddlewareContext, MiddlewareType
+
+ middleware_calls = []
+
+ @test_bot.middleware.register(MiddlewareType.PRE_COMMAND)
+ def test_middleware(ctx):
+ middleware_calls.append(ctx)
+ return ctx.data
+
+ # Execute middleware
+ ctx = MiddlewareContext(MiddlewareType.PRE_COMMAND, {"test": "data"})
+ result = test_bot.middleware.execute(MiddlewareType.PRE_COMMAND, ctx)
+
+ assert len(middleware_calls) == 1
+ assert middleware_calls[0].data["test"] == "data"
+ assert result == {"test": "data"}
+
+ def test_middleware_cancellation(self, test_bot):
+ """Test middleware can cancel processing."""
+ from lxmfy.middleware import MiddlewareContext, MiddlewareType
+
+ @test_bot.middleware.register(MiddlewareType.PRE_COMMAND)
+ def cancelling_middleware(ctx):
+ ctx.cancel()
+
+ ctx = MiddlewareContext(MiddlewareType.PRE_COMMAND, {"test": "data"})
+ result = test_bot.middleware.execute(MiddlewareType.PRE_COMMAND, ctx)
+
+ assert ctx.cancelled
+ assert result is None
+
+
+class TestSchedulerSystem:
+ """Test task scheduler integration."""
+
+ def test_scheduler_creation(self, test_bot):
+ """Test scheduler is created and functional."""
+ assert test_bot.scheduler is not None
+ assert hasattr(test_bot.scheduler, "tasks")
+ assert hasattr(test_bot.scheduler, "add_task")
+
+ def test_task_scheduling(self, test_bot):
+ """Test task scheduling functionality."""
+ task_calls = []
+
+ def test_task():
+ task_calls.append("executed")
+
+ # Schedule a task
+ test_bot.scheduler.add_task("test_task", test_task, "*/1 * * * *")
+
+ assert "test_task" in test_bot.scheduler.tasks
+ task = test_bot.scheduler.tasks["test_task"]
+ assert task.name == "test_task"
+ assert task.callback == test_task
+
+ # Manually execute task
+ task.callback()
+ assert len(task_calls) == 1
+ assert task_calls[0] == "executed"

diff --git a/vendor/lxmfy/tests/test_leaks.py b/vendor/lxmfy/tests/test_leaks.py
new file mode 100644
index 00000000..fc103d23
--- /dev/null
+++ b/vendor/lxmfy/tests/test_leaks.py
@@ -0,0 +1,101 @@
+"""Leak and resource tests for LXMFy."""
+
+import threading
+import psutil
+import pytest
+import gc
+from lxmfy import BotConfig, LXMFBot
+from unittest.mock import MagicMock
+
+
+def get_open_fds():
+ """Get count of open file descriptors."""
+ return psutil.Process().num_fds()
+
+
+def get_thread_count():
+ """Get count of active threads."""
+ return threading.active_count()
+
+
+def get_memory_usage():
+ """Get current memory usage in bytes."""
+ return psutil.Process().memory_info().rss
+
+
+@pytest.mark.reliability
+class TestLeaks:
+ """Test for resource leaks in LXMFy."""
+
+ def test_memory_leak_intent_classifier(self):
+ """Check for memory leaks in IntentClassifier training/prediction."""
+ from lxmfy.nlp import IntentClassifier
+
+ gc.collect()
+ start_mem = get_memory_usage()
+
+ # Run many iterations
+ for i in range(100):
+ nlp = IntentClassifier()
+ nlp.add_intent(f"intent_{i}", [f"example text {j}" for j in range(10)])
+ for _ in range(50):
+ nlp.predict("some random text")
+ del nlp
+ if i % 10 == 0:
+ gc.collect()
+
+ gc.collect()
+ end_mem = get_memory_usage()
+
+ # Allow some growth for fragmentation/internal caches but not massive
+ # 5MB is a very generous threshold for this test
+ assert (end_mem - start_mem) < 5 * 1024 * 1024
+
+ def test_fd_leak_bot_restarts(self, test_config_dir):
+ """Check for file descriptor leaks when repeatedly creating/destroying bots."""
+ gc.collect()
+ start_fds = get_open_fds()
+
+ for i in range(20):
+ config = BotConfig(
+ name=f"LeakBot_{i}",
+ storage_path=str(test_config_dir / f"leak_storage_{i}"),
+ test_mode=True,
+ )
+ bot = LXMFBot(**config.__dict__)
+ bot.router = MagicMock() # Avoid RNS starting real background threads
+ # Do some operations
+ bot.storage.set("test", "data")
+ bot.storage.get("test")
+ # Shutdown/Cleanup
+ bot.cleanup()
+ del bot
+ gc.collect()
+
+ end_fds = get_open_fds()
+
+ # We allow a small increase if some RNS/internal things don't close immediately,
+ # but it shouldn't be proportional to iterations.
+ assert end_fds <= start_fds + 5
+
+ def test_thread_leak_bot_creation(self, test_config_dir):
+ """Check for thread leaks when creating bots."""
+ gc.collect()
+ start_threads = get_thread_count()
+
+ for i in range(10):
+ config = BotConfig(
+ name=f"ThreadBot_{i}",
+ storage_path=str(test_config_dir / f"thread_storage_{i}"),
+ test_mode=True,
+ )
+ bot = LXMFBot(**config.__dict__)
+ bot.router = MagicMock()
+ bot.cleanup()
+ del bot
+ gc.collect()
+
+ end_threads = get_thread_count()
+
+ # Threads should return to baseline
+ assert end_threads <= start_threads + 2

diff --git a/vendor/lxmfy/tests/test_links.py b/vendor/lxmfy/tests/test_links.py
new file mode 100644
index 00000000..e50668a1
--- /dev/null
+++ b/vendor/lxmfy/tests/test_links.py
@@ -0,0 +1,70 @@
+from unittest.mock import MagicMock, patch
+
+import RNS
+
+from lxmfy import BotConfig, LXMFBot
+
+
+def test_link_request_initiation():
+ """Test requesting an RNS link."""
+ config = BotConfig(link_support_enabled=True, test_mode=True)
+ bot = LXMFBot(**config.__dict__)
+
+ # Create a real RNS.Identity for testing
+ real_identity = RNS.Identity()
+ with patch("lxmfy.core.RNS.Identity.recall", return_value=real_identity):
+ with patch("lxmfy.core.RNS.Link") as mock_link_class:
+ mock_link = MagicMock()
+ mock_link_class.return_value = mock_link
+
+ dest_hash = "abc123def456"
+ link = bot.request_link(dest_hash)
+
+ assert link == mock_link
+ assert dest_hash in bot.links
+ mock_link_class.assert_called_once()
+
+
+def test_link_request_custom_appdata():
+ """Test requesting an RNS link with custom app_name and aspects."""
+ config = BotConfig(link_support_enabled=True, test_mode=True)
+ bot = LXMFBot(**config.__dict__)
+
+ real_identity = RNS.Identity()
+ with patch("lxmfy.core.RNS.Identity.recall", return_value=real_identity):
+ with patch("lxmfy.core.RNS.Destination") as mock_dest_class:
+ with patch("lxmfy.core.RNS.Link"):
+ dest_hash = "abc123def456"
+ bot.request_link(dest_hash, None, "custom_app", "aspect1", "aspect2")
+
+ mock_dest_class.assert_called_once_with(
+ real_identity,
+ RNS.Destination.OUT,
+ RNS.Destination.SINGLE,
+ "custom_app",
+ "aspect1",
+ "aspect2",
+ )
+
+
+def test_link_established_callback_routing():
+ """Test link established callback handling."""
+ config = BotConfig(link_support_enabled=True, test_mode=True)
+ bot = LXMFBot(**config.__dict__)
+
+ link_called = False
+
+ def on_link(link):
+ nonlocal link_called
+ link_called = True
+
+ bot.on_link(on_link)
+
+ # Mock an incoming link
+ mock_link = MagicMock()
+ mock_link.destination.hash = b"mock_hash"
+
+ bot._link_established(mock_link)
+
+ assert link_called is True
+ assert "6d6f636b5f68617368" in bot.links # hex of b"mock_hash"

diff --git a/vendor/lxmfy/tests/test_minimal_crash.py b/vendor/lxmfy/tests/test_minimal_crash.py
new file mode 100644
index 00000000..03759c23
--- /dev/null
+++ b/vendor/lxmfy/tests/test_minimal_crash.py
@@ -0,0 +1,10 @@
+import RNS
+
+
+def test_minimal():
+ assert True
+
+
+def test_identity_minimal():
+ i = RNS.Identity()
+ assert i is not None

diff --git a/vendor/lxmfy/tests/test_nlp.py b/vendor/lxmfy/tests/test_nlp.py
new file mode 100644
index 00000000..5fe05dd6
--- /dev/null
+++ b/vendor/lxmfy/tests/test_nlp.py
@@ -0,0 +1,65 @@
+from unittest.mock import MagicMock
+
+from lxmfy import BotConfig, LXMFBot
+
+
+def test_intent_classification_basic():
+ """Test the NLP intent classification engine."""
+ config = BotConfig(nlp_enabled=True, nlp_threshold=0.4, test_mode=True)
+ bot = LXMFBot(**config.__dict__)
+
+ # Register an intent
+ @bot.intent(
+ "help",
+ examples=[
+ "how do i use this",
+ "show me commands",
+ "help me please",
+ "what can you do",
+ ],
+ )
+ def help_intent(msg):
+ msg.reply("I am here to help!")
+
+ # Test exact match (token based)
+ intent, score = bot.nlp.predict("help")
+ assert intent == "help"
+ assert score > 0.5
+
+ # Test semantic similarity (subset of words)
+ intent, score = bot.nlp.predict("how use this")
+ assert intent == "help"
+ assert score > 0.3
+
+ # Test no match
+ intent, score = bot.nlp.predict("completely unrelated text")
+ assert intent is None
+
+
+def test_intent_routing_message_flow():
+ """Test that the bot correctly routes messages to intent handlers."""
+ config = BotConfig(
+ nlp_enabled=True,
+ nlp_threshold=0.4,
+ test_mode=True,
+ command_prefix="/",
+ )
+ bot = LXMFBot(**config.__dict__)
+
+ intent_called = False
+
+ @bot.intent("greet", examples=["hello bot", "hi there", "greetings", "hi bot"])
+ def greet_intent(msg):
+ nonlocal intent_called
+ intent_called = True
+ msg.reply("Hello human!")
+
+ # Mock message
+ msg = MagicMock()
+ msg.content = b"hi bot" # Should match 'hi there' or 'hello bot'
+ msg.hash = b"mock_hash"
+
+ # Process message
+ bot._process_message(msg, "test_sender")
+
+ assert intent_called is True

diff --git a/vendor/lxmfy/tests/test_nlp_manifolds.py b/vendor/lxmfy/tests/test_nlp_manifolds.py
new file mode 100644
index 00000000..b52b3fa9
--- /dev/null
+++ b/vendor/lxmfy/tests/test_nlp_manifolds.py
@@ -0,0 +1,112 @@
+"""Advanced mathematical and manifold testing for NLP and protocol logic."""
+
+from lxmfy.nlp import IntentClassifier
+
+
+class TestNLPManifolds:
+ """Mathematical verification of intent vector space."""
+
+ def test_intent_orthogonality_matrix(self):
+ """Verify that registered intents are mathematically distinct."""
+ nlp = IntentClassifier(threshold=0.5)
+
+ # Add potentially confusing intents
+ intents = {
+ "shutdown": [
+ "turn off the bot",
+ "shutdown now",
+ "stop the service",
+ "kill process",
+ ],
+ "status": [
+ "what is the status",
+ "is the bot running",
+ "check service health",
+ "is it alive",
+ ],
+ "restart": [
+ "restart the bot",
+ "reboot service",
+ "turn off and on again",
+ "refresh process",
+ ],
+ "help": [
+ "how do i use this",
+ "show me commands",
+ "help me please",
+ "what can you do",
+ ],
+ }
+
+ for name, examples in intents.items():
+ nlp.add_intent(name, examples)
+
+ nlp.train()
+
+ # Calculate Cosine Similarity Matrix between all intents
+ # We compare the average vector (or best match) of each intent against others
+ intent_names = list(intents.keys())
+ collisions = []
+
+ for i, name1 in enumerate(intent_names):
+ for j, name2 in enumerate(intent_names):
+ if i >= j:
+ continue
+
+ # Check cross-similarity
+ # We use the internal processed_examples to find max similarity between two intent sets
+ max_cross_sim = 0
+ for v1, mag1 in nlp._processed_examples[name1]:
+ for v2, mag2 in nlp._processed_examples[name2]:
+ sim = nlp._cosine_similarity(v1, mag1, v2, mag2)
+ if sim > max_cross_sim:
+ max_cross_sim = sim
+
+ # If similarity is too high (> 0.7), the manifold is ambiguous
+ if max_cross_sim > 0.7:
+ collisions.append((name1, name2, max_cross_sim))
+
+ # Report collisions
+ if collisions:
+ msg = "\n".join(
+ [
+ f"Ambiguous intent pair: {n1} <-> {n2} (Sim: {s:.4f})"
+ for n1, n2, s in collisions
+ ]
+ )
+ # We don't necessarily fail unless it's extreme, but we want to know
+ print(f"\n[NLP Manifold Warning] {msg}")
+
+ # A hard failure if two different intents are identical or nearly identical
+ for n1, n2, s in collisions:
+ assert s < 0.9, (
+ f"Intents '{n1}' and '{n2}' are mathematically indistinguishable (Sim: {s})"
+ )
+
+ def test_vector_space_density(self):
+ """Test how intent classification behaves with varying training density."""
+ # Test with 1 example vs 10 examples
+ nlp_sparse = IntentClassifier(threshold=0.4)
+ nlp_dense = IntentClassifier(threshold=0.4)
+
+ nlp_sparse.add_intent("help", ["help"])
+ nlp_dense.add_intent(
+ "help", ["help", "i need help", "assist me", "show commands", "manual"]
+ )
+
+ nlp_sparse.train()
+ nlp_dense.train()
+
+ # Test a variation
+ query = "help me please"
+ _, score_sparse = nlp_sparse.predict(query)
+ _, score_dense = nlp_dense.predict(query)
+
+ # Dense training should generally provide better or equal confidence for variations
+ # due to more anchor points in the manifold
+ print(
+ f"\n[NLP Density] Sparse score: {score_sparse:.4f}, Dense score: {score_dense:.4f}"
+ )
+ # Note: In TF-IDF, score_dense might be lower if "help" is less rare,
+ # but bigrams/trigrams help stability.
+ assert score_dense > 0 or score_sparse > 0

diff --git a/vendor/lxmfy/tests/test_nlp_robustness.py b/vendor/lxmfy/tests/test_nlp_robustness.py
new file mode 100644
index 00000000..4f397486
--- /dev/null
+++ b/vendor/lxmfy/tests/test_nlp_robustness.py
@@ -0,0 +1,89 @@
+from lxmfy.nlp import IntentClassifier
+
+
+def test_intent_classification_robustness():
+ """Test that the NLP engine can handle common typos and variations."""
+ nlp = IntentClassifier(threshold=0.35, use_char_ngrams=True)
+ nlp.add_intent(
+ "help",
+ ["how do i use this", "show me commands", "help me please", "what can you do"],
+ )
+
+ # Test cases for typos
+ typo_cases = [
+ ("h3lp", "help"),
+ ("hlp", "help"),
+ ("show commands", "help"),
+ ("what can u do", "help"),
+ ("how use this", "help"),
+ ]
+
+ for input_text, expected_intent in typo_cases:
+ intent, score = nlp.predict(input_text)
+ assert intent == expected_intent, (
+ f"Failed to match '{input_text}' to '{expected_intent}' (score: {score})"
+ )
+
+
+def test_intent_classification_accuracy():
+ """Generate a confusion matrix report for the NLP engine."""
+ nlp = IntentClassifier(threshold=0.3, use_char_ngrams=True)
+
+ intents = {
+ "greeting": ["hello", "hi there", "greetings", "good morning"],
+ "help": ["how do i use this", "show me commands", "help me please"],
+ "status": ["what is your status", "are you online", "is everything okay"],
+ "shutdown": ["turn off", "stop running", "shutdown bot"],
+ }
+
+ for name, examples in intents.items():
+ nlp.add_intent(name, examples)
+
+ report = []
+ report.append("\n" + "=" * 50)
+ report.append("NLP CONFUSION MATRIX REPORT")
+ report.append("=" * 50)
+ report.append(f"{'Actual Intent':<15} | {'Predicted Intent':<15} | {'Score':<6}")
+ report.append("-" * 45)
+
+ total_tests = 0
+ correct_matches = 0
+
+ for actual_name, examples in intents.items():
+ for example in examples:
+ total_tests += 1
+ predicted_name, score = nlp.predict(example)
+ report.append(
+ f"{actual_name:<15} | {predicted_name!s:<15} | {score:.4f}",
+ )
+ if predicted_name == actual_name:
+ correct_matches += 1
+
+ accuracy = (correct_matches / total_tests) * 100 if total_tests > 0 else 0
+ report.append("-" * 45)
+ report.append(f"Total Accuracy: {accuracy:.2f}% ({correct_matches}/{total_tests})")
+ report.append("=" * 50 + "\n")
+
+ # Print report to stdout when running with -s
+ print("\n".join(report))
+
+ assert accuracy > 90, "NLP classification accuracy is too low"
+
+
+def test_intent_classification_discrimination():
+ """Test that unrelated text does not trigger intents."""
+ nlp = IntentClassifier(threshold=0.5, use_char_ngrams=True)
+ nlp.add_intent("help", ["how do i use this", "show me commands", "help me please"])
+
+ unrelated_texts = [
+ "the weather is nice today",
+ "i like to eat pizza",
+ "quantum physics is complex",
+ "1234567890",
+ ]
+
+ for text in unrelated_texts:
+ intent, score = nlp.predict(text)
+ assert intent is None, (
+ f"False positive triggered for '{text}' (intent: {intent}, score: {score})"
+ )

diff --git a/vendor/lxmfy/tests/test_performance.py b/vendor/lxmfy/tests/test_performance.py
new file mode 100644
index 00000000..059118dd
--- /dev/null
+++ b/vendor/lxmfy/tests/test_performance.py
@@ -0,0 +1,232 @@
+"""Performance and stress tests for LXMFy."""
+
+import os
+import time
+from unittest.mock import MagicMock, patch
+
+import LXMF
+import psutil
+import pytest
+import RNS
+
+from lxmfy import BotConfig, LXMFBot
+from lxmfy.middleware import MiddlewareType
+
+
+def get_memory_usage():
+ """Get current memory usage in MB."""
+ process = psutil.Process(os.getpid())
+ return process.memory_info().rss / (1024 * 1024)
+
+
+class TestPerformance:
+ """Stress tests for LXMFy."""
+
+ @pytest.fixture
+ def stress_bot(self, test_config_dir):
+ """Create a bot configured for stress testing."""
+ config = BotConfig(
+ name="StressBot",
+ test_mode=True,
+ storage_path=str(test_config_dir / "stress_storage"),
+ signature_verification_enabled=True,
+ require_message_signatures=False,
+ rate_limit=10000, # High limit for testing
+ cooldown=1,
+ cogs_enabled=False,
+ first_message_enabled=False, # Disable first message handling for performance tests
+ )
+ bot = LXMFBot(**config.__dict__)
+ # Mock some components to avoid RNS overhead in performance tests
+ bot.router = MagicMock()
+ bot.local = MagicMock()
+ bot.local.hash = b"local_identity_hash"
+ return bot
+
+ def test_message_processing_flood(self, stress_bot):
+ """Flood the bot with messages to test processing throughput."""
+ message_count = 10000 # Increased to 10k
+ messages = []
+
+ processed_count = 0
+
+ def count_handler(sender, msg):
+ nonlocal processed_count
+ processed_count += 1
+ return True
+
+ stress_bot.message_handlers.append(count_handler)
+
+ # Pre-generate messages
+ for i in range(message_count):
+ mock_msg = MagicMock(spec=LXMF.LXMessage)
+ mock_msg.content = f"Stress test message {i}".encode()
+ mock_msg.title = f"Title {i}".encode()
+ mock_msg.source_hash = b"source_" + str(i % 10).encode().rjust(10, b"0")
+ mock_msg.destination_hash = b"local_identity_hash"
+ mock_msg.hash = b"hash_" + os.urandom(16)
+ mock_msg.fields = {}
+ mock_msg.signature_validated = True
+ messages.append(mock_msg)
+
+ start_mem = get_memory_usage()
+ start_time = time.time()
+
+ # Simulate receiving messages
+ for msg in messages:
+ stress_bot._message_received(msg)
+
+ duration = time.time() - start_time
+ end_mem = get_memory_usage()
+
+ print(f"\n[Performance] Processed {message_count} messages in {duration:.4f}s")
+ print(f"[Performance] Throughput: {message_count / duration:.2f} msg/s")
+ print(f"[Performance] Memory delta: {end_mem - start_mem:.2f} MB")
+
+ # Verify all messages were processed
+ assert processed_count == message_count
+ assert duration < 2.0 # Should be very fast in test mode
+
+ def test_signature_verification_stress(self, stress_bot):
+ """Test performance of signature verification logic."""
+ sig_manager = stress_bot.signature_manager
+ identity = RNS.Identity()
+ sender_hash = RNS.hexrep(identity.hash, delimit=False)
+
+ # Pre-generate a signed message
+ mock_msg = MagicMock(spec=LXMF.LXMessage)
+ mock_msg.content = b"Performance test content"
+ mock_msg.title = b"Performance test title"
+ mock_msg.source_hash = identity.hash
+ mock_msg.destination_hash = b"dest"
+ mock_msg.fields = {}
+ mock_msg.timestamp = int(time.time())
+
+ # Our signature logic uses _canonicalize_message
+ signature = sig_manager.sign_message(mock_msg, identity)
+
+ iteration_count = 5000 # Increased to 5k
+ start_time = time.time()
+
+ # Patch where it is USED, which is lxmfy.signatures
+ with patch("lxmfy.signatures.RNS.Identity.recall", return_value=identity):
+ for _ in range(iteration_count):
+ sig_manager.verify_message_signature(mock_msg, signature, sender_hash)
+
+ duration = time.time() - start_time
+ print(
+ f"\n[Performance] Verified {iteration_count} signatures in {duration:.4f}s",
+ )
+ print(
+ f"[Performance] Avg verification time: {(duration / iteration_count) * 1000:.4f}ms",
+ )
+
+ assert duration < 5.0
+
+ def test_storage_extreme_load(self, stress_bot):
+ """Test storage performance under heavy load."""
+ storage = stress_bot.storage
+ record_count = 10000 # Increased to 10k
+
+ start_mem = get_memory_usage()
+ start_time = time.time()
+
+ # Write load
+ for i in range(record_count):
+ storage.set(f"key_{i}", {"data": "value" * 10, "index": i})
+
+ write_duration = time.time() - start_time
+
+ # Read load
+ read_start = time.time()
+ for i in range(record_count):
+ storage.get(f"key_{i}")
+ read_duration = time.time() - read_start
+
+ end_mem = get_memory_usage()
+
+ print(f"\n[Performance] Storage {record_count} records:")
+ print(
+ f"[Performance] Write time: {write_duration:.4f}s ({record_count / write_duration:.2f} ops/s)",
+ )
+ print(
+ f"[Performance] Read time: {read_duration:.4f}s ({record_count / read_duration:.2f} ops/s)",
+ )
+ print(f"[Performance] Memory delta: {end_mem - start_mem:.2f} MB")
+
+ assert write_duration < 10.0
+ assert read_duration < 5.0
+
+ def test_heavy_middleware_load(self, stress_bot):
+ """Test impact of multiple middlewares on message processing."""
+ # Add multiple dummy middlewares
+ for i in range(10):
+
+ def make_middleware(idx):
+ def mw(ctx):
+ ctx.data[f"mw_{idx}"] = True
+ return ctx
+
+ return mw
+
+ stress_bot.middleware.register(MiddlewareType.PRE_EVENT, make_middleware(i))
+
+ message_count = 500
+
+ processed_count = 0
+
+ def count_handler(sender, msg):
+ nonlocal processed_count
+ processed_count += 1
+ return True
+
+ stress_bot.message_handlers.append(count_handler)
+
+ start_time = time.time()
+ for i in range(message_count):
+ mock_msg = MagicMock(spec=LXMF.LXMessage)
+ mock_msg.content = b"middleware test"
+ mock_msg.source_hash = b"source"
+ mock_msg.destination_hash = b"local"
+ mock_msg.hash = b"hash_" + str(i).encode()
+ mock_msg.fields = {}
+ mock_msg.signature_validated = True
+ stress_bot._message_received(mock_msg)
+
+ duration = time.time() - start_time
+ print(
+ f"\n[Performance] Middleware (10 layers) x {message_count} msgs: {duration:.4f}s",
+ )
+
+ assert processed_count == message_count
+ assert duration < 2.0
+
+ def test_memory_stability_long_run(self, stress_bot):
+ """Run bot for a period to check for memory leaks."""
+ duration_seconds = 5
+ start_time = time.time()
+ start_mem = get_memory_usage()
+
+ message_count = 0
+ mock_msg = MagicMock(spec=LXMF.LXMessage)
+ mock_msg.content = b"stability test"
+ mock_msg.source_hash = b"source"
+ mock_msg.destination_hash = b"local"
+ mock_msg.fields = {}
+ mock_msg.signature_validated = True
+
+ while time.time() - start_time < duration_seconds:
+ mock_msg.hash = os.urandom(16)
+ stress_bot._message_received(mock_msg)
+ message_count += 1
+
+ end_mem = get_memory_usage()
+ print("\n[Performance] Stability run (5s):")
+ print(f"[Performance] Total messages: {message_count}")
+ print(f"[Performance] Start memory: {start_mem:.2f} MB")
+ print(f"[Performance] End memory: {end_mem:.2f} MB")
+ print(f"[Performance] Memory delta: {end_mem - start_mem:.2f} MB")
+
+ # Stability check: Memory shouldn't grow boundlessly
+ # We allow some growth for caches but not massive
+ assert (end_mem - start_mem) < 10.0

diff --git a/vendor/lxmfy/tests/test_propagation.py b/vendor/lxmfy/tests/test_propagation.py
new file mode 100644
index 00000000..9e1079d3
--- /dev/null
+++ b/vendor/lxmfy/tests/test_propagation.py
@@ -0,0 +1,503 @@
+"""Tests for propagation node functionality."""
+
+import uuid
+from unittest.mock import MagicMock
+
+import pytest
+import RNS
+
+from lxmfy import BotConfig, LXMFBot
+
+
+class TestPropagationConfiguration:
+ """Test propagation node configuration options."""
+
+ def test_manual_propagation_node_config(self, test_config_dir):
+ """Test manual propagation node configuration."""
+ unique_config_path = test_config_dir / f"manual_prop_{uuid.uuid4().hex[:8]}"
+ unique_config_path.mkdir(exist_ok=True)
+
+ test_identity = RNS.Identity()
+ identity_file = unique_config_path / "identity"
+ test_identity.to_file(str(identity_file))
+
+ original_from_file = RNS.Identity.from_file
+ RNS.Identity.from_file = lambda path: test_identity
+
+ try:
+ prop_node_hash = "1234567890abcdef1234567890abcdef"
+
+ config = BotConfig(
+ name="ManualPropBot",
+ propagation_node=prop_node_hash,
+ propagation_fallback_enabled=True,
+ storage_path=str(unique_config_path / "storage"),
+ )
+ bot = LXMFBot(**config.__dict__)
+ bot.config_path = str(unique_config_path)
+
+ assert bot.config.propagation_node == prop_node_hash
+ assert bot.config.propagation_fallback_enabled is True
+ assert bot.config.autopeer_propagation is False
+
+ # Test that outbound propagation node is set
+ if not bot.config.test_mode:
+ configured_node = bot.router.get_outbound_propagation_node()
+ if configured_node:
+ assert RNS.hexrep(configured_node, delimit=False) == prop_node_hash
+
+ bot.cleanup()
+ finally:
+ RNS.Identity.from_file = original_from_file
+
+ def test_autopeer_propagation_config(self, test_config_dir):
+ """Test autopeer propagation configuration."""
+ unique_config_path = test_config_dir / f"autopeer_{uuid.uuid4().hex[:8]}"
+ unique_config_path.mkdir(exist_ok=True)
+
+ test_identity = RNS.Identity()
+ identity_file = unique_config_path / "identity"
+ test_identity.to_file(str(identity_file))
+
+ original_from_file = RNS.Identity.from_file
+ RNS.Identity.from_file = lambda path: test_identity
+
+ try:
+ config = BotConfig(
+ name="AutopeerBot",
+ autopeer_propagation=True,
+ autopeer_maxdepth=4,
+ propagation_fallback_enabled=True,
+ storage_path=str(unique_config_path / "storage"),
+ )
+ bot = LXMFBot(**config.__dict__)
+ bot.config_path = str(unique_config_path)
+
+ assert bot.config.autopeer_propagation is True
+ assert bot.config.autopeer_maxdepth == 4
+ assert bot.config.propagation_fallback_enabled is True
+
+ # Test router autopeer settings
+ if not bot.config.test_mode:
+ assert bot.router.autopeer is True
+ assert bot.router.autopeer_maxdepth == 4
+
+ bot.cleanup()
+ finally:
+ RNS.Identity.from_file = original_from_file
+
+ def test_propagation_node_enabled(self, test_config_dir):
+ """Test enabling propagation node mode."""
+ unique_config_path = test_config_dir / f"propnode_{uuid.uuid4().hex[:8]}"
+ unique_config_path.mkdir(exist_ok=True)
+
+ test_identity = RNS.Identity()
+ identity_file = unique_config_path / "identity"
+ test_identity.to_file(str(identity_file))
+
+ original_from_file = RNS.Identity.from_file
+ RNS.Identity.from_file = lambda path: test_identity
+
+ try:
+ config = BotConfig(
+ name="PropNodeBot",
+ enable_propagation_node=True,
+ message_storage_limit_mb=500,
+ storage_path=str(unique_config_path / "storage"),
+ test_mode=True,
+ )
+ bot = LXMFBot(**config.__dict__)
+ bot.config_path = str(unique_config_path)
+
+ # Mock router for test mode
+ bot.router = MagicMock()
+ bot.router.propagation_node = True
+
+ assert bot.config.enable_propagation_node is True
+ assert bot.config.message_storage_limit_mb == 500
+
+ # Test router propagation node mode
+ if not bot.config.test_mode:
+ assert bot.router.propagation_node is True
+
+ bot.cleanup()
+ finally:
+ RNS.Identity.from_file = original_from_file
+
+ def test_default_propagation_config(self, test_bot):
+ """Test default propagation configuration."""
+ assert test_bot.config.propagation_fallback_enabled is True
+ assert test_bot.config.propagation_node is None
+ assert test_bot.config.autopeer_propagation is False
+ assert test_bot.config.autopeer_maxdepth == 4
+ assert test_bot.config.enable_propagation_node is False
+ assert test_bot.config.message_storage_limit_mb == 500.0
+
+
+class TestMessageStorageLimits:
+ """Test message storage limit functionality for propagation nodes."""
+
+ def test_default_storage_limit(self, test_bot):
+ """Test default storage limit configuration."""
+ assert test_bot.config.message_storage_limit_mb == 500.0
+
+ def test_custom_storage_limit(self, test_config_dir):
+ """Test custom storage limit configuration."""
+ unique_config_path = test_config_dir / f"storage_limit_{uuid.uuid4().hex[:8]}"
+ unique_config_path.mkdir(exist_ok=True)
+
+ test_identity = RNS.Identity()
+ identity_file = unique_config_path / "identity"
+ test_identity.to_file(str(identity_file))
+
+ original_from_file = RNS.Identity.from_file
+ RNS.Identity.from_file = lambda path: test_identity
+
+ try:
+ config = BotConfig(
+ name="LimitBot",
+ enable_propagation_node=True,
+ message_storage_limit_mb=1000,
+ storage_path=str(unique_config_path / "storage"),
+ test_mode=True,
+ )
+ bot = LXMFBot(**config.__dict__)
+ bot.config_path = str(unique_config_path)
+
+ # Mock router for test mode
+ bot.router = MagicMock()
+ bot.router.message_storage_limit = 1000 * 1000 * 1000
+
+ assert bot.config.message_storage_limit_mb == 1000
+
+ # Test router storage limit
+ if not bot.config.test_mode:
+ expected_bytes = 1000 * 1000 * 1000
+ assert bot.router.message_storage_limit == expected_bytes
+
+ bot.cleanup()
+ finally:
+ RNS.Identity.from_file = original_from_file
+
+ def test_set_storage_limit_method(self, test_config_dir):
+ """Test setting storage limit at runtime."""
+ unique_config_path = test_config_dir / f"set_limit_{uuid.uuid4().hex[:8]}"
+ unique_config_path.mkdir(exist_ok=True)
+
+ test_identity = RNS.Identity()
+ identity_file = unique_config_path / "identity"
+ test_identity.to_file(str(identity_file))
+
+ original_from_file = RNS.Identity.from_file
+ RNS.Identity.from_file = lambda path: test_identity
+
+ try:
+ config = BotConfig(
+ name="DynamicLimitBot",
+ enable_propagation_node=True,
+ message_storage_limit_mb=500,
+ storage_path=str(unique_config_path / "storage"),
+ )
+ bot = LXMFBot(**config.__dict__)
+ bot.config_path = str(unique_config_path)
+
+ if not bot.config.test_mode:
+ # Change limit at runtime
+ bot.set_message_storage_limit(megabytes=2000)
+ assert bot.config.message_storage_limit_mb == 2000
+ assert bot.router.message_storage_limit == 2000 * 1000 * 1000
+
+ bot.cleanup()
+ finally:
+ RNS.Identity.from_file = original_from_file
+
+ def test_storage_limit_not_propagation_node(self, test_bot):
+ """Test storage limit warning when not a propagation node."""
+ # Should not crash, just log warning
+ test_bot.set_message_storage_limit(megabytes=1000)
+ # If we get here without exception, test passes
+
+ def test_storage_limit_test_mode(self, test_bot):
+ """Test storage limit in test mode."""
+ assert test_bot.config.test_mode is True
+ # Should not crash in test mode
+ test_bot.set_message_storage_limit(megabytes=1000)
+
+
+class TestPropagationHelperMethods:
+ """Test propagation node helper methods."""
+
+ def test_get_propagation_status_test_mode(self, test_bot):
+ """Test getting propagation status in test mode."""
+ status = test_bot.get_propagation_node_status()
+
+ assert status is not None
+ assert "test_mode" in status
+ assert status["test_mode"] is True
+
+ def test_get_propagation_status_with_config(self, test_config_dir):
+ """Test getting propagation status with various configurations."""
+ unique_config_path = test_config_dir / f"status_{uuid.uuid4().hex[:8]}"
+ unique_config_path.mkdir(exist_ok=True)
+
+ test_identity = RNS.Identity()
+ identity_file = unique_config_path / "identity"
+ test_identity.to_file(str(identity_file))
+
+ original_from_file = RNS.Identity.from_file
+ RNS.Identity.from_file = lambda path: test_identity
+
+ try:
+ config = BotConfig(
+ name="StatusBot",
+ propagation_node="1234567890abcdef1234567890abcdef",
+ autopeer_propagation=True,
+ autopeer_maxdepth=3,
+ enable_propagation_node=False,
+ storage_path=str(unique_config_path / "storage"),
+ )
+ bot = LXMFBot(**config.__dict__)
+ bot.config_path = str(unique_config_path)
+
+ status = bot.get_propagation_node_status()
+
+ assert status is not None
+ assert "manual_node" in status
+ assert status["manual_node"] == "1234567890abcdef1234567890abcdef"
+ assert "autopeer_enabled" in status
+ assert status["autopeer_enabled"] is True
+ assert "autopeer_maxdepth" in status
+ assert status["autopeer_maxdepth"] == 3
+ assert "is_propagation_node" in status
+ assert status["is_propagation_node"] is False
+
+ bot.cleanup()
+ finally:
+ RNS.Identity.from_file = original_from_file
+
+ def test_set_propagation_node_method(self, test_config_dir):
+ """Test setting propagation node at runtime."""
+ unique_config_path = test_config_dir / f"set_node_{uuid.uuid4().hex[:8]}"
+ unique_config_path.mkdir(exist_ok=True)
+
+ test_identity = RNS.Identity()
+ identity_file = unique_config_path / "identity"
+ test_identity.to_file(str(identity_file))
+
+ original_from_file = RNS.Identity.from_file
+ RNS.Identity.from_file = lambda path: test_identity
+
+ try:
+ config = BotConfig(
+ name="SetNodeBot",
+ storage_path=str(unique_config_path / "storage"),
+ )
+ bot = LXMFBot(**config.__dict__)
+ bot.config_path = str(unique_config_path)
+
+ new_node = "abcdef1234567890abcdef1234567890"
+
+ if not bot.config.test_mode:
+ bot.set_propagation_node(new_node)
+ assert bot.config.propagation_node == new_node
+
+ bot.cleanup()
+ finally:
+ RNS.Identity.from_file = original_from_file
+
+ def test_set_propagation_node_invalid_hash(self, test_config_dir):
+ """Test setting invalid propagation node hash."""
+ unique_config_path = test_config_dir / f"invalid_{uuid.uuid4().hex[:8]}"
+ unique_config_path.mkdir(exist_ok=True)
+
+ test_identity = RNS.Identity()
+ identity_file = unique_config_path / "identity"
+ test_identity.to_file(str(identity_file))
+
+ original_from_file = RNS.Identity.from_file
+ RNS.Identity.from_file = lambda path: test_identity
+
+ try:
+ config = BotConfig(
+ name="InvalidHashBot",
+ storage_path=str(unique_config_path / "storage"),
+ )
+ bot = LXMFBot(**config.__dict__)
+ bot.config_path = str(unique_config_path)
+
+ if not bot.config.test_mode:
+ with pytest.raises(ValueError):
+ bot.set_propagation_node("not_a_valid_hex_hash")
+
+ bot.cleanup()
+ finally:
+ RNS.Identity.from_file = original_from_file
+
+ def test_set_propagation_node_test_mode(self, test_bot):
+ """Test setting propagation node in test mode."""
+ # Should not crash in test mode
+ test_bot.set_propagation_node("1234567890abcdef1234567890abcdef")
+
+ def test_get_storage_stats_not_propagation_node(self, test_bot):
+ """Test getting storage stats when not a propagation node."""
+ stats = test_bot.get_propagation_storage_stats()
+
+ assert stats is not None
+ assert "is_propagation_node" in stats or "test_mode" in stats
+
+ def test_get_storage_stats_test_mode(self, test_bot):
+ """Test getting storage stats in test mode."""
+ stats = test_bot.get_propagation_storage_stats()
+
+ assert stats is not None
+ assert "test_mode" in stats
+ assert stats["test_mode"] is True
+
+
+class TestPropagationWarnings:
+ """Test that appropriate warnings are logged for propagation misconfiguration."""
+
+ def test_warning_propagation_enabled_no_node(self, test_config_dir):
+ """Test warning when propagation fallback enabled but no node configured."""
+ unique_config_path = test_config_dir / f"warning_{uuid.uuid4().hex[:8]}"
+ unique_config_path.mkdir(exist_ok=True)
+
+ test_identity = RNS.Identity()
+ identity_file = unique_config_path / "identity"
+ test_identity.to_file(str(identity_file))
+
+ original_from_file = RNS.Identity.from_file
+ RNS.Identity.from_file = lambda path: test_identity
+
+ try:
+ # This configuration should trigger a warning
+ config = BotConfig(
+ name="WarningBot",
+ propagation_fallback_enabled=True,
+ propagation_node=None,
+ autopeer_propagation=False,
+ enable_propagation_node=False,
+ storage_path=str(unique_config_path / "storage"),
+ )
+
+ # Should not crash, just log warning
+ bot = LXMFBot(**config.__dict__)
+ bot.config_path = str(unique_config_path)
+
+ # Bot should still be created successfully
+ assert bot is not None
+ assert bot.config.propagation_fallback_enabled is True
+
+ bot.cleanup()
+ finally:
+ RNS.Identity.from_file = original_from_file
+
+ def test_no_warning_with_manual_node(self, test_config_dir):
+ """Test no warning when manual propagation node is configured."""
+ unique_config_path = test_config_dir / f"no_warn_{uuid.uuid4().hex[:8]}"
+ unique_config_path.mkdir(exist_ok=True)
+
+ test_identity = RNS.Identity()
+ identity_file = unique_config_path / "identity"
+ test_identity.to_file(str(identity_file))
+
+ original_from_file = RNS.Identity.from_file
+ RNS.Identity.from_file = lambda path: test_identity
+
+ try:
+ config = BotConfig(
+ name="NoWarnBot",
+ propagation_fallback_enabled=True,
+ propagation_node="1234567890abcdef1234567890abcdef",
+ storage_path=str(unique_config_path / "storage"),
+ )
+
+ bot = LXMFBot(**config.__dict__)
+ bot.config_path = str(unique_config_path)
+
+ assert bot is not None
+ assert bot.config.propagation_node is not None
+
+ bot.cleanup()
+ finally:
+ RNS.Identity.from_file = original_from_file
+
+ def test_no_warning_with_autopeer(self, test_config_dir):
+ """Test no warning when autopeer is enabled."""
+ unique_config_path = test_config_dir / f"autopeer_warn_{uuid.uuid4().hex[:8]}"
+ unique_config_path.mkdir(exist_ok=True)
+
+ test_identity = RNS.Identity()
+ identity_file = unique_config_path / "identity"
+ test_identity.to_file(str(identity_file))
+
+ original_from_file = RNS.Identity.from_file
+ RNS.Identity.from_file = lambda path: test_identity
+
+ try:
+ config = BotConfig(
+ name="AutopeerNoWarnBot",
+ propagation_fallback_enabled=True,
+ autopeer_propagation=True,
+ storage_path=str(unique_config_path / "storage"),
+ )
+
+ bot = LXMFBot(**config.__dict__)
+ bot.config_path = str(unique_config_path)
+
+ assert bot is not None
+ assert bot.config.autopeer_propagation is True
+
+ bot.cleanup()
+ finally:
+ RNS.Identity.from_file = original_from_file
+
+
+class TestPropagationDeliveryMethod:
+ """Test propagation delivery method selection."""
+
+ def test_direct_delivery_initially(self, test_bot):
+ """Test that direct delivery is used initially."""
+ test_destination = "1234567890abcdef1234567890abcdef"
+
+ # No failed attempts yet
+ assert test_bot.delivery_attempts.get(test_destination, 0) == 0
+
+ def test_propagation_after_retries(self, test_bot):
+ """Test propagation fallback after failed direct deliveries."""
+ test_destination = "1234567890abcdef1234567890abcdef"
+
+ # Simulate failed delivery attempts
+ max_retries = test_bot.config.direct_delivery_retries
+ test_bot.delivery_attempts[test_destination] = max_retries
+
+ # Next send should use propagation (if enabled)
+ attempts = test_bot.delivery_attempts.get(test_destination, 0)
+ assert attempts >= max_retries
+
+ def test_delivery_attempts_tracking(self, test_bot):
+ """Test delivery attempts are tracked correctly."""
+ test_destination = "1234567890abcdef1234567890abcdef"
+
+ # Load initial attempts
+ test_bot._load_delivery_attempts()
+
+ # Set some attempts
+ test_bot.delivery_attempts[test_destination] = 2
+ test_bot._save_delivery_attempts()
+
+ # Reload and verify
+ test_bot._load_delivery_attempts()
+ assert test_bot.delivery_attempts[test_destination] == 2
+
+ def test_reset_delivery_attempts(self, test_bot):
+ """Test delivery attempts reset on successful delivery."""
+ test_destination = "1234567890abcdef1234567890abcdef"
+
+ # Set failed attempts
+ test_bot.delivery_attempts[test_destination] = 5
+
+ # Reset on success
+ test_bot._reset_delivery_attempts(test_destination)
+
+ assert test_bot.delivery_attempts[test_destination] == 0

diff --git a/vendor/lxmfy/tests/test_real_world_e2e.py b/vendor/lxmfy/tests/test_real_world_e2e.py
new file mode 100644
index 00000000..b4d9f636
--- /dev/null
+++ b/vendor/lxmfy/tests/test_real_world_e2e.py
@@ -0,0 +1,148 @@
+"""Real-world E2E tests for LXMFy using multiprocessing."""
+
+import multiprocessing
+import os
+import time
+
+import pytest
+import RNS
+
+from lxmfy import BotConfig, LXMFBot
+
+
+def create_rns_config(config_dir, listen_port, target_port=None):
+ """Create a Reticulum config file."""
+ config_path = config_dir / "config"
+ content = f"""
+[reticulum]
+loglevel = 7
+storagepath = {config_dir}/storage
+share_instance = No
+discovery_enabled = No
+discover_interfaces = No
+
+[interfaces]
+"""
+ if target_port:
+ content += f"""
+ [[TCPClientInterface]]
+ type = TCPClientInterface
+ interface_enabled = True
+ outgoing = True
+ target_host = 127.0.0.1
+ target_port = {target_port}
+"""
+ else:
+ content += f"""
+ [[TCPServerInterface]]
+ type = TCPServerInterface
+ interface_enabled = True
+ outgoing = True
+ listen_port = {listen_port}
+"""
+
+ with open(config_path, "w") as f:
+ f.write(content)
+ return config_path
+
+
+def bot_process(
+ name,
+ config_dir,
+ port,
+ target_port,
+ ready_event,
+ stop_event,
+ results_queue,
+):
+ """Run a bot in a separate process."""
+ os.environ["RNS_CONFIG_DIR"] = str(config_dir)
+
+ config = BotConfig(
+ name=name,
+ config_path=str(config_dir),
+ storage_path=str(config_dir / "db"),
+ announce_enabled=True,
+ test_mode=False,
+ )
+
+ try:
+ bot = LXMFBot(**config.__dict__)
+
+ @bot.received
+ def on_received(sender, message):
+ results_queue.put(
+ f"RECEIVED_BY_{name}_FROM_{sender}:{message.content.decode()}",
+ )
+
+ results_queue.put(f"READY_{name}_{RNS.hexrep(bot.local.hash, delimit=False)}")
+ ready_event.set()
+
+ while not stop_event.is_set():
+ # Process any outbound messages in the queue
+ for _ in range(bot.queue.qsize()):
+ lxm = bot.queue.get()
+ bot.router.handle_outbound(lxm)
+
+ # Check for commands from results_queue (used as bi-directional for simplicity here)
+ # Actually better use a separate command queue
+ time.sleep(0.1)
+
+ except Exception as e:
+ results_queue.put(f"ERROR_{name}_{e}")
+ finally:
+ if "bot" in locals():
+ bot.cleanup()
+ # Force exit to ensure RNS threads die
+ os._exit(0)
+
+
+class TestRealWorldE2E:
+ """E2E tests using separate processes."""
+
+ @pytest.mark.skip(reason="Still too flaky in this environment")
+ def test_two_bots_full_exchange(self, test_config_dir):
+ """Test two bots communicating over real TCP interfaces."""
+ # Setup directories
+ bot_a_dir = test_config_dir / "bot_a"
+ bot_b_dir = test_config_dir / "bot_b"
+ bot_a_dir.mkdir(exist_ok=True)
+ bot_b_dir.mkdir(exist_ok=True)
+ (bot_a_dir / "storage").mkdir(exist_ok=True)
+ (bot_b_dir / "storage").mkdir(exist_ok=True)
+
+ create_rns_config(bot_a_dir, 42451)
+ create_rns_config(bot_b_dir, 42452, target_port=42451)
+
+ ready_a = multiprocessing.Event()
+ ready_b = multiprocessing.Event()
+ stop_event = multiprocessing.Event()
+ results = multiprocessing.Queue()
+
+ proc_a = multiprocessing.Process(
+ target=bot_process,
+ args=("BotA", bot_a_dir, 42451, None, ready_a, stop_event, results),
+ )
+ proc_b = multiprocessing.Process(
+ target=bot_process,
+ args=("BotB", bot_b_dir, 42452, 42451, ready_b, stop_event, results),
+ )
+
+ try:
+ proc_a.start()
+ proc_b.start()
+
+ # Wait for readiness
+ assert ready_a.wait(timeout=30), "Bot A failed to start"
+ assert ready_b.wait(timeout=30), "Bot B failed to start"
+
+ # ... exchange messages ...
+
+ finally:
+ stop_event.set()
+ proc_a.join(timeout=2)
+ proc_b.join(timeout=2)
+ if proc_a.is_alive():
+ proc_a.terminate()
+ if proc_b.is_alive():
+ proc_b.terminate()

diff --git a/vendor/lxmfy/tests/test_realistic_external_cogs.py b/vendor/lxmfy/tests/test_realistic_external_cogs.py
new file mode 100644
index 00000000..5360c83c
--- /dev/null
+++ b/vendor/lxmfy/tests/test_realistic_external_cogs.py
@@ -0,0 +1,172 @@
+import os
+import shutil
+import stat
+import subprocess
+import tempfile
+from pathlib import Path
+from unittest.mock import MagicMock
+
+import pytest
+
+from lxmfy import BotConfig, LXMFBot
+from lxmfy.cogs_core import load_cogs_from_directory
+
+
+def is_bwrap_functional():
+ """Check if bwrap can actually run a simple command in this environment."""
+ try:
+ # Construct a realistic minimal bwrap command
+ cmd = ["bwrap", "--unshare-all", "--ro-bind", "/usr", "/usr"]
+ for p in ["/bin", "/lib", "/lib64", "/sbin"]:
+ if os.path.islink(p):
+ cmd.extend(["--symlink", os.readlink(p), p])
+ elif os.path.exists(p):
+ cmd.extend(["--ro-bind", p, p])
+ cmd.extend(["/usr/bin/true"])
+
+ result = subprocess.run(cmd, capture_output=True, text=True)
+ return result.returncode == 0
+ except Exception:
+ return False
+
+
+@pytest.fixture
+def realistic_cogs_setup():
+ """Set up a temporary directory with various language cogs."""
+ with tempfile.TemporaryDirectory() as temp_dir:
+ temp_path = Path(temp_dir)
+ cogs_dir = temp_path / "cogs"
+ cogs_dir.mkdir()
+
+ # 1. Bash Cog
+ bash_path = cogs_dir / "bash_hello"
+ bash_path.write_text('#!/bin/bash\necho "Bash: Hello $1"')
+ os.chmod(bash_path, os.stat(bash_path).st_mode | stat.S_IEXEC)
+
+ # 2. Perl Cog
+ perl_path = cogs_dir / "perl_hello"
+ perl_path.write_text('#!/usr/bin/perl\nprint "Perl: Hello $ARGV[0]\\n";')
+ os.chmod(perl_path, os.stat(perl_path).st_mode | stat.S_IEXEC)
+
+ # 3. C Cog
+ c_bin = None
+ if shutil.which("gcc"):
+ c_src = temp_path / "hello.c"
+ c_src.write_text("""
+ #include <stdio.h>
+ int main(int argc, char *argv[]) {
+ if (argc > 1) {
+ printf("C: Hello %s\\n", argv[1]);
+ }
+ return 0;
+ }
+ """)
+ c_bin = cogs_dir / "c_hello"
+ subprocess.run(["gcc", str(c_src), "-o", str(c_bin)], check=True)
+ # os.chmod is usually set by gcc, but let's be sure
+ os.chmod(c_bin, os.stat(c_bin).st_mode | stat.S_IEXEC)
+
+ # 4. Go Cog
+ go_bin = None
+ if shutil.which("go"):
+ go_src = temp_path / "hello.go"
+ go_src.write_text("""
+ package main
+ import (
+ "fmt"
+ "os"
+ )
+ func main() {
+ if len(os.Args) > 1 {
+ fmt.Printf("Go: Hello %s\\n", os.Args[1])
+ }
+ }
+ """)
+ go_bin = cogs_dir / "go_hello"
+ # Compile with CGO_ENABLED=0 for a static binary (easier for sandbox)
+ env = os.environ.copy()
+ env["CGO_ENABLED"] = "0"
+ subprocess.run(
+ ["go", "build", "-o", str(go_bin), str(go_src)],
+ env=env,
+ check=True,
+ )
+
+ yield temp_path
+
+
+def test_multilang_cogs_execution(realistic_cogs_setup):
+ """Test that cogs in different languages execute correctly without sandbox."""
+ config = BotConfig(
+ name="TestBot",
+ test_mode=True,
+ external_cogs_enabled=True,
+ external_cogs_sandbox_enabled=False,
+ config_path=str(realistic_cogs_setup),
+ storage_path=str(realistic_cogs_setup / "storage"),
+ )
+ bot = LXMFBot(**config.__dict__)
+ bot.config_path = str(realistic_cogs_setup)
+
+ load_cogs_from_directory(bot)
+
+ languages = {
+ "bash_hello": "Bash: Hello test_sender",
+ "perl_hello": "Perl: Hello test_sender",
+ }
+ if shutil.which("gcc"):
+ languages["c_hello"] = "C: Hello test_sender"
+ if shutil.which("go"):
+ languages["go_hello"] = "Go: Hello test_sender"
+
+ for cmd_name, expected_output in languages.items():
+ assert cmd_name in bot.commands
+ msg = MagicMock()
+ msg.sender = "test_sender"
+ msg.content = f"/{cmd_name}"
+ msg.args = []
+
+ bot.commands[cmd_name].callback(msg)
+ msg.reply.assert_called_with(expected_output)
+
+
+@pytest.mark.skipif(
+ not is_bwrap_functional(),
+ reason="bwrap is not functional in this environment",
+)
+def test_bwrap_sandbox_isolation(realistic_cogs_setup):
+ """Test that bwrap sandbox actually isolates the process."""
+ # Create a script that tries to read a file outside the sandbox
+ secret_file = realistic_cogs_setup / "host_secret.txt"
+ secret_file.write_text("HOST_SECRET_DATA")
+
+ cogs_dir = realistic_cogs_setup / "cogs"
+ leak_script = cogs_dir / "leak_test"
+ # Try to cat a file that isn't bound in bwrap
+ leak_script.write_text(f"#!/bin/bash\ncat {secret_file} 2>&1")
+ os.chmod(leak_script, os.stat(leak_script).st_mode | stat.S_IEXEC)
+
+ config = BotConfig(
+ name="TestBot",
+ test_mode=True,
+ external_cogs_enabled=True,
+ external_cogs_sandbox_enabled=True,
+ external_cogs_sandbox_type="bwrap",
+ config_path=str(realistic_cogs_setup),
+ storage_path=str(realistic_cogs_setup / "storage"),
+ )
+ bot = LXMFBot(**config.__dict__)
+ bot.config_path = str(realistic_cogs_setup)
+
+ load_cogs_from_directory(bot)
+
+ msg = MagicMock()
+ msg.sender = "test_sender"
+ bot.commands["leak_test"].callback(msg)
+
+ # It should fail to read the file
+ args, _ = msg.reply.call_args
+ output = args[0]
+ # The handler replies with "Error executing command: ..." on non-zero exit
+ assert "Error executing command" in output
+ assert "HOST_SECRET_DATA" not in output

diff --git a/vendor/lxmfy/tests/test_recursive_cogs.py b/vendor/lxmfy/tests/test_recursive_cogs.py
new file mode 100644
index 00000000..c4c67157
--- /dev/null
+++ b/vendor/lxmfy/tests/test_recursive_cogs.py
@@ -0,0 +1,52 @@
+import os
+import stat
+import tempfile
+from pathlib import Path
+
+from lxmfy import BotConfig, LXMFBot
+from lxmfy.cogs_core import load_cogs_from_directory
+
+
+def test_cog_execution_depth_limit(test_config_dir):
+ """Test that external cogs cannot trigger infinite recursive bot calls."""
+ # This is a bit complex to test fully without a real bot instance,
+ # but we can verify the threading and isolation logic.
+
+ with tempfile.TemporaryDirectory() as temp_dir:
+ temp_path = Path(temp_dir)
+ cogs_dir = temp_path / "cogs"
+ cogs_dir.mkdir()
+
+ # Create a cog that tries to call the bot recursively
+ # (simulated by executing another command)
+ script_path = cogs_dir / "recursive.sh"
+ script_content = """#!/bin/bash
+echo "Calling self..."
+# In a real scenario, this might try to send an LXMF message back to the bot
+# which would trigger another command execution.
+"""
+ script_path.write_text(script_content)
+ os.chmod(script_path, os.stat(script_path).st_mode | stat.S_IEXEC)
+
+ config = BotConfig(
+ name="DepthTestBot",
+ test_mode=True,
+ external_cogs_enabled=True,
+ external_cogs_timeout=2,
+ config_path=str(temp_path),
+ storage_path=str(temp_path / "storage"),
+ )
+ bot = LXMFBot(**config.__dict__)
+ bot.config_path = str(temp_path)
+
+ load_cogs_from_directory(bot)
+ cmd = bot.commands["recursive"]
+
+ # Since we use threading and subprocess.run with timeout,
+ # any actual recursive fork-bomb would be caught by the process limit
+ # or the bot's thread pool executor limit.
+
+ assert cmd.threaded is True
+
+ # Verify the bot's thread pool is initialized
+ assert bot.thread_pool._max_workers == 5

diff --git a/vendor/lxmfy/tests/test_reliability.py b/vendor/lxmfy/tests/test_reliability.py
new file mode 100644
index 00000000..df6f5241
--- /dev/null
+++ b/vendor/lxmfy/tests/test_reliability.py
@@ -0,0 +1,130 @@
+"""Reliability and crash recovery tests for LXMFy."""
+
+import os
+import pytest
+import time
+from lxmfy import BotConfig, LXMFBot
+from unittest.mock import MagicMock
+
+
+@pytest.mark.reliability
+class TestReliability:
+ """Long-term reliability and crash recovery tests."""
+
+ def test_state_persistence_after_crash(self, test_config_dir):
+ """Simulate a crash and verify state recovery from storage."""
+ storage_path = str(test_config_dir / "recovery_storage")
+
+ # 1. Setup bot and save some state
+ config1 = BotConfig(
+ name="PersistentBot", storage_path=storage_path, test_mode=True
+ )
+ bot1 = LXMFBot(**config1.__dict__)
+ bot1.storage.set("important_state", {"data": 123})
+ bot1.storage.set("user_pref:abc", "value1")
+
+ # 2. Simulate "crash" by deleting the bot instance without graceful shutdown
+ del bot1
+
+ # 3. Restart bot with same storage
+ config2 = BotConfig(
+ name="PersistentBot", storage_path=storage_path, test_mode=True
+ )
+ bot2 = LXMFBot(**config2.__dict__)
+
+ # 4. Verify state is recovered
+ assert bot2.storage.get("important_state") == {"data": 123}
+ assert bot2.storage.get("user_pref:abc") == "value1"
+
+ def test_corrupted_storage_recovery(self, test_config_dir):
+ """Verify that the bot can handle/recover from corrupted storage files."""
+ storage_path = test_config_dir / "corrupt_storage"
+ storage_path.mkdir(parents=True, exist_ok=True)
+
+ # Create a corrupted JSON file in storage
+ corrupt_file = storage_path / "corrupt_data.json"
+ with open(corrupt_file, "w") as f:
+ f.write("{ invalid json...")
+
+ config = BotConfig(
+ name="CorruptBot", storage_path=str(storage_path), test_mode=True
+ )
+ # Initialization should not crash despite corrupted file
+ bot = LXMFBot(**config.__dict__)
+ assert bot.storage is not None
+
+ # Attempting to read corrupted data should return None or handle gracefully
+ # Depending on how Storage is implemented
+ val = bot.storage.get("corrupt_data")
+ assert val is None or isinstance(val, dict) # Should not raise Exception
+
+ def test_long_running_message_processing(self, test_config_dir):
+ """Simulate a long-running bot processing messages continuously."""
+ config = BotConfig(
+ name="StableBot",
+ storage_path=str(test_config_dir / "stable_storage"),
+ test_mode=True,
+ )
+ bot = LXMFBot(**config.__dict__)
+ bot.router = MagicMock()
+
+ processed_count = 0
+
+ def handler(sender, msg):
+ nonlocal processed_count
+ processed_count += 1
+ return True
+
+ bot.message_handlers.append(handler)
+
+ import LXMF
+
+ # Process messages in a loop for a short while
+ # In a real reliability test this would run for hours
+ start_time = time.time()
+ while time.time() - start_time < 2: # 2 seconds of intense processing
+ mock_msg = MagicMock(spec=LXMF.LXMessage)
+ mock_msg.content = b"test content"
+ mock_msg.hash = os.urandom(16)
+ mock_msg.source_hash = b"source"
+ mock_msg.destination_hash = (
+ bot.local.hash if hasattr(bot.local, "hash") else b"local"
+ )
+ mock_msg.fields = {}
+ mock_msg.signature_validated = True
+ bot._message_received(mock_msg)
+
+ assert processed_count > 0
+ print(f"Processed {processed_count} messages reliably.")
+
+ def test_concurrent_storage_access(self, test_config_dir):
+ """Test storage reliability under concurrent access from multiple threads."""
+ from threading import Thread
+
+ config = BotConfig(
+ name="ConcurrentBot",
+ storage_path=str(test_config_dir / "concurrent_storage"),
+ test_mode=True,
+ )
+ bot = LXMFBot(**config.__dict__)
+
+ errors = []
+
+ def worker(worker_id):
+ try:
+ for i in range(100):
+ key = f"thread_{worker_id}_key_{i}"
+ bot.storage.set(key, {"val": i})
+ val = bot.storage.get(key)
+ if val["val"] != i:
+ errors.append(f"Data mismatch in worker {worker_id}")
+ except Exception as e:
+ errors.append(f"Exception in worker {worker_id}: {e}")
+
+ threads = [Thread(target=worker, args=(i,)) for i in range(5)]
+ for t in threads:
+ t.start()
+ for t in threads:
+ t.join()
+
+ assert not errors, f"Concurrent storage errors: {errors}"

diff --git a/vendor/lxmfy/tests/test_robustness_nlp.py b/vendor/lxmfy/tests/test_robustness_nlp.py
new file mode 100644
index 00000000..b8be4857
--- /dev/null
+++ b/vendor/lxmfy/tests/test_robustness_nlp.py
@@ -0,0 +1,64 @@
+"""Property-based robustness tests for NLP module."""
+
+from hypothesis import given, strategies as st
+from lxmfy.nlp import IntentClassifier
+
+
+class TestNLPRobustnessProperty:
+ """Property-based tests for IntentClassifier."""
+
+ @given(
+ intents=st.dictionaries(
+ keys=st.text(
+ min_size=1,
+ max_size=10,
+ alphabet=st.characters(whitelist_categories=("Lu", "Ll", "Nd")),
+ ),
+ values=st.lists(st.text(min_size=1, max_size=50), min_size=1, max_size=5),
+ min_size=1,
+ max_size=10,
+ ),
+ threshold=st.floats(min_value=0.0, max_value=1.0),
+ )
+ def test_nlp_never_crashes_on_training(self, intents, threshold):
+ """Verify that IntentClassifier can be trained with any input without crashing."""
+ nlp = IntentClassifier(threshold=threshold)
+ for name, examples in intents.items():
+ nlp.add_intent(name, examples)
+ nlp.train()
+
+ @given(
+ examples=st.lists(st.text(min_size=1, max_size=100), min_size=1, max_size=10),
+ query=st.text(max_size=500),
+ threshold=st.floats(min_value=0.0, max_value=1.0),
+ )
+ def test_nlp_prediction_robustness(self, examples, query, threshold):
+ """Verify that prediction logic handles any query string gracefully."""
+ nlp = IntentClassifier(threshold=threshold)
+ nlp.add_intent("test_intent", examples)
+
+ # Should not crash
+ intent, score = nlp.predict(query)
+
+ assert 0.0 <= score <= 1.000000000000001
+ if intent is not None:
+ assert intent == "test_intent"
+
+ @given(text=st.text(max_size=1000))
+ def test_tokenization_robustness(self, text):
+ """Verify that internal tokenization handles all unicode characters."""
+ tokens = IntentClassifier._tokenize(text)
+ assert isinstance(tokens, list)
+ for t in tokens:
+ assert isinstance(t, str)
+
+ @given(text=st.text(max_size=100), n=st.integers(min_value=1, max_value=10))
+ def test_ngram_robustness(self, text, n):
+ """Verify that n-gram generation handles all inputs and n values."""
+ ngrams = IntentClassifier._get_char_ngrams(text, n=n)
+ assert isinstance(ngrams, list)
+ if len(text) + 2 < n: # +2 for ^ and $
+ assert len(ngrams) == 0
+ else:
+ for ng in ngrams:
+ assert len(ng) == n

diff --git a/vendor/lxmfy/tests/test_signatures.py b/vendor/lxmfy/tests/test_signatures.py
new file mode 100644
index 00000000..b7096f36
--- /dev/null
+++ b/vendor/lxmfy/tests/test_signatures.py
@@ -0,0 +1,443 @@
+"""Tests for LXMFy signature functionality."""
+
+from unittest.mock import MagicMock, patch
+
+import LXMF
+import pytest
+import RNS
+
+from lxmfy.signatures import (
+ FIELD_SIGNATURE,
+ SignatureManager,
+ sign_outgoing_message,
+ verify_incoming_message,
+)
+
+
+class TestSignatureManager:
+ """Test SignatureManager class."""
+
+ def test_init(self):
+ """Test SignatureManager initialization."""
+ bot = MagicMock()
+ sig_manager = SignatureManager(
+ bot,
+ verification_enabled=True,
+ require_signatures=True,
+ request_unknown_identities=True,
+ )
+
+ assert sig_manager.bot == bot
+ assert sig_manager.verification_enabled is True
+ assert sig_manager.require_signatures is True
+ assert sig_manager.request_unknown_identities is True
+
+ def test_init_defaults(self):
+ """Test SignatureManager initialization with defaults."""
+ bot = MagicMock()
+ sig_manager = SignatureManager(bot)
+
+ assert sig_manager.verification_enabled is False
+ assert sig_manager.require_signatures is False
+
+ def test_sign_message_success(self):
+ """Test successful message signing."""
+ bot = MagicMock()
+ sig_manager = SignatureManager(bot)
+ identity = RNS.Identity()
+
+ # Create a proper mock message
+ mock_message = MagicMock()
+ mock_message.source_hash = b"source_hash"
+ mock_message.destination_hash = b"dest_hash"
+ mock_message.content = b"test content"
+ mock_message.title = b"test title"
+ mock_message.fields = {}
+
+ # Mock the canonicalize method to return predictable data
+ expected_data = (
+ b"source:source_hash|dest:dest_hash|content:test content|title:test title"
+ )
+ with patch.object(
+ sig_manager,
+ "_canonicalize_message",
+ return_value=expected_data,
+ ):
+ result = sig_manager.sign_message(mock_message, identity)
+
+ assert isinstance(result, bytes)
+ assert len(result) > 0 # Should have a signature
+
+ def test_sign_message_exception(self):
+ """Test sign_message with exception."""
+ bot = MagicMock()
+ sig_manager = SignatureManager(bot)
+ identity = RNS.Identity()
+ mock_message = MagicMock()
+
+ # Mock the _canonicalize_message to raise an exception
+ with (
+ patch.object(
+ sig_manager,
+ "_canonicalize_message",
+ side_effect=Exception("Test error"),
+ ),
+ pytest.raises(Exception, match="Test error"),
+ ):
+ sig_manager.sign_message(mock_message, identity)
+
+ def test_verify_message_signature_success(self):
+ """Test successful signature verification."""
+ bot = MagicMock()
+ sig_manager = SignatureManager(bot)
+ identity = RNS.Identity()
+
+ # Create a message and sign it
+ mock_message = MagicMock()
+ mock_message.source_hash = b"source_hash"
+ mock_message.destination_hash = b"dest_hash"
+ mock_message.content = b"test content"
+ mock_message.title = b"test title"
+ mock_message.fields = {}
+
+ # Sign the message
+ signature = sig_manager.sign_message(mock_message, identity)
+ mock_message.fields[FIELD_SIGNATURE] = signature
+
+ # Verify the signature
+ sender_hash = RNS.hexrep(identity.hash, delimit=False)
+ result = sig_manager.verify_message_signature(
+ mock_message,
+ signature,
+ sender_hash,
+ identity,
+ )
+
+ assert result is True
+
+ def test_verify_message_signature_invalid(self):
+ """Test signature verification with invalid signature."""
+ bot = MagicMock()
+ sig_manager = SignatureManager(bot)
+ identity = RNS.Identity()
+
+ mock_message = MagicMock()
+ mock_message.source_hash = b"source_hash"
+ mock_message.destination_hash = b"dest_hash"
+ mock_message.content = b"test content"
+ mock_message.title = b"test title"
+ mock_message.fields = {}
+
+ # Use a fake signature
+ fake_signature = b"fake_signature"
+
+ sender_hash = RNS.hexrep(identity.hash, delimit=False)
+ result = sig_manager.verify_message_signature(
+ mock_message,
+ fake_signature,
+ sender_hash,
+ identity,
+ )
+
+ assert result is False
+
+ def test_verify_message_signature_no_identity_recall(self):
+ """Test signature verification when identity recall fails."""
+ bot = MagicMock()
+ sig_manager = SignatureManager(bot)
+
+ mock_message = MagicMock()
+ mock_message.source_hash = b"source_hash"
+ mock_message.destination_hash = b"dest_hash"
+ mock_message.content = b"test content"
+ mock_message.title = b"test title"
+
+ # Use a non-existent hash
+ fake_hash = "nonexistent"
+ fake_signature = b"fake_signature"
+
+ result = sig_manager.verify_message_signature(
+ mock_message,
+ fake_signature,
+ fake_hash,
+ )
+
+ assert result is False
+
+ def test_verify_message_signature_exception(self):
+ """Test signature verification with exception."""
+ bot = MagicMock()
+ sig_manager = SignatureManager(bot)
+
+ mock_message = MagicMock()
+ mock_message.source_hash = b"source_hash"
+
+ # Mock _canonicalize_message to raise exception
+ with patch.object(
+ sig_manager,
+ "_canonicalize_message",
+ side_effect=Exception("Test error"),
+ ):
+ result = sig_manager.verify_message_signature(
+ mock_message,
+ b"signature",
+ "fake_hash",
+ )
+ assert result is False
+
+ def test_canonicalize_message_basic(self):
+ """Test basic message canonicalization."""
+ bot = MagicMock()
+ sig_manager = SignatureManager(bot)
+
+ mock_message = MagicMock()
+ mock_message.source_hash = b"source_hash"
+ mock_message.destination_hash = b"dest_hash"
+ mock_message.content = b"test content"
+ mock_message.title = b"test title"
+ mock_message.timestamp = 1234567890
+ mock_message.fields = {1: b"field1", FIELD_SIGNATURE: b"signature"}
+
+ result = sig_manager._canonicalize_message(mock_message)
+
+ expected_parts = [
+ b"source:736f757263655f68617368", # hex of b"source_hash"
+ b"dest:646573745f68617368", # hex of b"dest_hash"
+ b"content:test content",
+ b"title:test title",
+ b"timestamp:1234567890",
+ b"field_1:b'field1'",
+ ]
+ expected = b"|".join(expected_parts)
+
+ assert result == expected
+
+ def test_canonicalize_message_minimal(self):
+ """Test message canonicalization with minimal fields."""
+ bot = MagicMock()
+ sig_manager = SignatureManager(bot)
+
+ mock_message = MagicMock()
+ mock_message.source_hash = None
+ mock_message.destination_hash = None
+ mock_message.content = None
+ mock_message.title = None
+ mock_message.timestamp = None
+ mock_message.fields = None
+
+ result = sig_manager._canonicalize_message(mock_message)
+
+ assert result == b""
+
+ def test_should_verify_message_enabled(self):
+ """Test should_verify_message when verification is enabled."""
+ bot = MagicMock()
+ # Remove permissions attribute to simulate no permissions system
+ del bot.permissions
+ sig_manager = SignatureManager(bot, verification_enabled=True)
+
+ result = sig_manager.should_verify_message("sender_hash")
+ assert result is True
+
+ def test_should_verify_message_disabled(self):
+ """Test should_verify_message when verification is disabled."""
+ bot = MagicMock()
+ sig_manager = SignatureManager(bot, verification_enabled=False)
+
+ result = sig_manager.should_verify_message("sender_hash")
+ assert result is False
+
+ def test_should_verify_message_bypass_permission(self):
+ """Test should_verify_message with bypass permission."""
+ bot = MagicMock()
+ bot.permissions = MagicMock()
+ bot.permissions.enabled = True
+ bot.permissions.has_permission.return_value = True
+
+ sig_manager = SignatureManager(bot, verification_enabled=True)
+
+ result = sig_manager.should_verify_message("sender_hash")
+ assert result is False
+ bot.permissions.has_permission.assert_called_once()
+
+ def test_should_verify_message_no_bypass(self):
+ """Test should_verify_message without bypass permission."""
+ bot = MagicMock()
+ bot.permissions = MagicMock()
+ bot.permissions.enabled = True
+ bot.permissions.has_permission.return_value = False
+
+ sig_manager = SignatureManager(bot, verification_enabled=True)
+
+ result = sig_manager.should_verify_message("sender_hash")
+ assert result is True
+
+ def test_handle_unsigned_message_require_signatures(self):
+ """Test handle_unsigned_message when signatures are required."""
+ bot = MagicMock()
+ sig_manager = SignatureManager(bot, require_signatures=True)
+
+ result = sig_manager.handle_unsigned_message("sender_hash", "message_hash")
+ assert result is False
+
+ def test_handle_unsigned_message_verification_enabled(self):
+ """Test handle_unsigned_message when verification is enabled but not required."""
+ bot = MagicMock()
+ sig_manager = SignatureManager(
+ bot,
+ verification_enabled=True,
+ require_signatures=False,
+ )
+
+ result = sig_manager.handle_unsigned_message("sender_hash", "message_hash")
+ assert result is True
+
+ def test_handle_unsigned_message_disabled(self):
+ """Test handle_unsigned_message when verification is disabled."""
+ bot = MagicMock()
+ sig_manager = SignatureManager(bot)
+
+ result = sig_manager.handle_unsigned_message("sender_hash", "message_hash")
+ assert result is True
+
+
+class TestSignatureFunctions:
+ """Test signature utility functions."""
+
+ def test_sign_outgoing_message_passthrough(self):
+ """Test sign_outgoing_message is a pass-through (LXMF handles signing)."""
+ bot = MagicMock()
+ bot.signature_manager = MagicMock()
+ bot.signature_manager.verification_enabled = True
+ bot.identity = RNS.Identity()
+
+ mock_message = MagicMock()
+ mock_message.fields = {}
+
+ result = sign_outgoing_message(bot, mock_message)
+
+ assert result == mock_message
+ # Should not modify fields - LXMF will handle signing during pack()
+
+ def test_verify_incoming_message_valid_signature(self):
+ """Test verify_incoming_message with valid LXMF signature."""
+ bot = MagicMock()
+ bot.signature_manager = MagicMock()
+ bot.signature_manager.should_verify_message.return_value = True
+
+ mock_message = MagicMock()
+ mock_message.signature_validated = True
+ mock_message.hash = b"message_hash"
+
+ result = verify_incoming_message(bot, mock_message, "sender_hash")
+
+ assert result is True
+
+ def test_verify_incoming_message_skip_verification(self):
+ """Test verify_incoming_message when verification should be skipped."""
+ bot = MagicMock()
+ bot.signature_manager = MagicMock()
+ bot.signature_manager.should_verify_message.return_value = False
+
+ mock_message = MagicMock()
+
+ result = verify_incoming_message(bot, mock_message, "sender_hash")
+
+ assert result is True
+
+ def test_verify_incoming_message_invalid_signature(self):
+ """Test verify_incoming_message when LXMF signature is invalid."""
+ bot = MagicMock()
+ bot.signature_manager = MagicMock()
+ bot.signature_manager.should_verify_message.return_value = True
+
+ mock_message = MagicMock()
+ mock_message.signature_validated = False
+ mock_message.unverified_reason = LXMF.LXMessage.SIGNATURE_INVALID
+ mock_message.hash = b"message_hash"
+
+ result = verify_incoming_message(bot, mock_message, "sender_hash")
+
+ assert result is False
+
+ def test_verify_incoming_message_source_unknown_require_sig(self):
+ """Test verify_incoming_message when source is unknown and signatures required."""
+ bot = MagicMock()
+ bot.signature_manager = MagicMock()
+ bot.signature_manager.should_verify_message.return_value = True
+ bot.signature_manager.require_signatures = True
+
+ mock_message = MagicMock()
+ mock_message.signature_validated = False
+ mock_message.unverified_reason = LXMF.LXMessage.SOURCE_UNKNOWN
+ mock_message.hash = b"message_hash"
+
+ result = verify_incoming_message(bot, mock_message, "sender_hash")
+
+ assert result is False
+
+ def test_verify_incoming_message_source_unknown_not_required(self):
+ """Test verify_incoming_message when source is unknown but signatures not required."""
+ bot = MagicMock()
+ bot.signature_manager = MagicMock()
+ bot.signature_manager.should_verify_message.return_value = True
+ bot.signature_manager.require_signatures = False
+
+ mock_message = MagicMock()
+ mock_message.signature_validated = False
+ mock_message.unverified_reason = LXMF.LXMessage.SOURCE_UNKNOWN
+ mock_message.hash = b"message_hash"
+
+ result = verify_incoming_message(bot, mock_message, "sender_hash")
+
+ assert result is True
+
+ def test_verify_incoming_message_unverified_other_reason(self):
+ """Test verify_incoming_message with other unverified reason."""
+ bot = MagicMock()
+ bot.signature_manager = MagicMock()
+ bot.signature_manager.should_verify_message.return_value = True
+ bot.signature_manager.handle_unsigned_message.return_value = True
+
+ mock_message = MagicMock()
+ mock_message.signature_validated = False
+ mock_message.unverified_reason = 999 # Some other reason
+ mock_message.hash = b"message_hash"
+
+ result = verify_incoming_message(bot, mock_message, "sender_hash")
+
+ assert result is True
+ bot.signature_manager.handle_unsigned_message.assert_called_once()
+
+ def test_verify_incoming_message_source_unknown_request_ident(self):
+ """Test verify_incoming_message when source is unknown and requesting is enabled."""
+ bot = MagicMock()
+ bot.signature_manager = MagicMock()
+ bot.signature_manager.should_verify_message.return_value = True
+ bot.signature_manager.request_unknown_identities = True
+ bot.signature_manager.requested_identities = set()
+ bot.signature_manager.require_signatures = False
+
+ mock_message = MagicMock()
+ mock_message.signature_validated = False
+ mock_message.unverified_reason = LXMF.LXMessage.SOURCE_UNKNOWN
+ mock_message.hash = b"message_hash"
+
+ with patch("lxmfy.signatures.RNS.Transport.request_path") as mock_request_path:
+ result = verify_incoming_message(bot, mock_message, "abcdef1234567890")
+
+ assert result is True
+ mock_request_path.assert_called_once()
+ assert "abcdef1234567890" in bot.signature_manager.requested_identities
+
+ def test_verify_incoming_message_no_manager(self):
+ """Test verify_incoming_message when no signature manager exists."""
+ bot = MagicMock()
+ # Remove signature_manager attribute to simulate it not existing
+ del bot.signature_manager
+
+ mock_message = MagicMock()
+
+ result = verify_incoming_message(bot, mock_message, "sender_hash")
+
+ assert result is True

diff --git a/vendor/lxmfy/tests/test_smoke.py b/vendor/lxmfy/tests/test_smoke.py
new file mode 100644
index 00000000..4c0fcef2
--- /dev/null
+++ b/vendor/lxmfy/tests/test_smoke.py
@@ -0,0 +1,51 @@
+"""Smoke tests for LXMFy."""
+
+from lxmfy import BotConfig, LXMFBot
+
+
+def test_bot_initialization(test_config_dir):
+ """Verify that the bot can be initialized with basic config."""
+ config = BotConfig(
+ name="SmokeBot",
+ storage_path=str(test_config_dir / "smoke_storage"),
+ test_mode=True,
+ )
+ bot = LXMFBot(**config.__dict__)
+ assert bot.config.name == "SmokeBot"
+ assert bot.storage is not None
+ assert bot.transport is not None
+
+
+def test_cog_loading(test_config_dir):
+ """Verify that cogs can be enabled and disabled."""
+ config = BotConfig(
+ name="CogBot",
+ storage_path=str(test_config_dir / "cog_storage"),
+ test_mode=True,
+ cogs_enabled=True,
+ )
+ bot = LXMFBot(**config.__dict__)
+ assert bot.config.cogs_enabled is True
+
+
+def test_nlp_smoke():
+ """Verify that the NLP engine basic prediction works."""
+ from lxmfy.nlp import IntentClassifier
+
+ nlp = IntentClassifier(threshold=0.1)
+ nlp.add_intent("test", ["this is a test"])
+ intent, score = nlp.predict("this is a test")
+ assert intent == "test"
+ assert score > 0.1
+
+
+def test_storage_smoke(test_config_dir):
+ """Verify basic storage operations."""
+ from lxmfy.storage import Storage, JSONStorage
+
+ storage_path = test_config_dir / "storage_smoke"
+ storage = Storage(JSONStorage(str(storage_path)))
+ storage.set("test_key", "test_value")
+ assert storage.get("test_key") == "test_value"
+ storage.delete("test_key")
+ assert storage.get("test_key") is None


──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────